titan-vault-node 2.0.0 → 2.1.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 +28 -19
- package/dist/core/classify.d.ts +9 -0
- package/dist/core/classify.js +26 -0
- package/dist/core/crypto-store.d.ts +5 -9
- package/dist/core/crypto-store.js +20 -33
- package/dist/core/database-hooks.d.ts +4 -6
- package/dist/core/database-hooks.js +22 -40
- package/dist/core/hardening.d.ts +1 -1
- package/dist/core/hardening.js +41 -10
- package/dist/core/poison.js +4 -4
- package/dist/index.d.ts +13 -12
- package/dist/index.js +156 -131
- package/dist/types.d.ts +9 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Titan Vault (Node.js SDK)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cliente de la bóveda local. Pide `GET /secret/data/{path}` con una llave RSA-2048 efímera, descifra la respuesta híbrida (RSA-OAEP-SHA256 + AES-256-CFB) y reparte cada campo:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- `JWT_SECRET` y el resto de llaves quedan como `crypto.KeyObject`. El SDK no guarda otra copia en string. `getRaw()` sí crea un string, y el JSON descifrado vive en el heap hasta que V8 lo recolecta.
|
|
6
|
+
- `DB_PASSWORD` y las demás contraseñas de base de datos se entregan solo cuando Sequelize o `pg` piden la conexión.
|
|
7
|
+
- `DATABASE_URL`, `REDIS_URL`, `PORT` y el resto se leen con `TitanVault.config.get`. No entran en `process.env`, salvo `NODE_ENV`, `PORT`, `HOST`, `FRONTEND_PORT` y `BACKEND_PORT`.
|
|
8
|
+
- `VAULT_EMERGENCY_RECOVERY_TOKEN` no se puede leer. Si el payload trae el cebo, esa lectura llama a `GET /sys/recovery` con el valor que inyectó la bóveda.
|
|
9
|
+
|
|
10
|
+
Si la bóveda no responde, o devuelve el secreto en claro después de recibir `x-vault-public-key`, `armParanoiaMode` lanza `VaultConnectionError` y el proceso no debe seguir. El token se lee de `vaultToken`, `VAULT_TOKEN` o `VAULT_APP_TOKEN`.
|
|
6
11
|
|
|
7
12
|
---
|
|
8
13
|
|
|
@@ -14,17 +19,13 @@
|
|
|
14
19
|
|
|
15
20
|
---
|
|
16
21
|
|
|
17
|
-
##
|
|
18
|
-
|
|
19
|
-
`titan-vault-node` implementa una arquitectura de grado militar:
|
|
22
|
+
## Qué hace el modo paranoia
|
|
20
23
|
|
|
21
|
-
1. **
|
|
22
|
-
2.
|
|
23
|
-
3.
|
|
24
|
-
4. **
|
|
25
|
-
|
|
26
|
-
- **node-postgres (`pg`):** Función asíncrona de contraseña bajo demanda (`async () => password`).
|
|
27
|
-
5. **CaaS / Transit Engine:** Criptografía como Servicio. Permite firmar tokens o cifrar datos directamente en el servidor Titan Vault sin mantener ninguna llave en memoria.
|
|
24
|
+
1. **Arranque.** `bootstrap` espera a `armParanoiaMode`. Si esa llamada lanza, no importa el servidor.
|
|
25
|
+
2. **`process.env`.** Un proxy lanza `ParanoiaBlockedError` al leer o escribir un secreto, y lo oculta de `Object.keys` y `console.log(process.env)`.
|
|
26
|
+
3. **`KeyObject`.** `TitanVault.secrets.get('JWT_SECRET')` sirve para `jsonwebtoken` y `crypto`. La búsqueda de strings en el heap de V8 todavía puede encontrar el valor que produjo `JSON.parse` hasta que el recolector lo suelte.
|
|
27
|
+
4. **Base de datos.** `hookSequelize` pone la contraseña en `beforeConnect` y la quita del config en `afterConnect`. `getPgPasswordProvider` la devuelve en cada conexión. El driver conserva el string que necesita el handshake.
|
|
28
|
+
5. **Transit.** `encrypt`, `decrypt`, `sign` y `verify` llaman a `/transit/*` en la bóveda. La llave no sale del servidor.
|
|
28
29
|
|
|
29
30
|
---
|
|
30
31
|
|
|
@@ -39,7 +40,7 @@ Crea un archivo de arranque que cargue el SDK antes de importar el servidor:
|
|
|
39
40
|
import { TitanVault } from 'titan-vault-node';
|
|
40
41
|
|
|
41
42
|
async function main() {
|
|
42
|
-
//
|
|
43
|
+
// Lanza VaultConnectionError si la bóveda no responde.
|
|
43
44
|
await TitanVault.armParanoiaMode({
|
|
44
45
|
vaultUrl: process.env.VAULT_URL || 'http://127.0.0.1:8200',
|
|
45
46
|
vaultToken: process.env.VAULT_APP_TOKEN,
|
|
@@ -63,7 +64,6 @@ Compatible de forma nativa con `jsonwebtoken`, `jose` y el módulo `crypto` de N
|
|
|
63
64
|
import jwt from 'jsonwebtoken';
|
|
64
65
|
import { TitanVault } from 'titan-vault-node';
|
|
65
66
|
|
|
66
|
-
// Obtiene el KeyObject de C++ (Invisible en volcados de RAM)
|
|
67
67
|
const jwtKey = TitanVault.secrets.get('JWT_SECRET');
|
|
68
68
|
|
|
69
69
|
// Firmar
|
|
@@ -137,16 +137,21 @@ const isValid = await TitanVault.transit.verify('mi-firma-jwt', 'payload.a.firma
|
|
|
137
137
|
|
|
138
138
|
---
|
|
139
139
|
|
|
140
|
-
### 5.
|
|
140
|
+
### 5. Configuración
|
|
141
|
+
|
|
142
|
+
`DATABASE_URL` y `REDIS_URL` salen de aquí. No son contraseñas y no se copian a `process.env`.
|
|
141
143
|
|
|
142
144
|
```typescript
|
|
145
|
+
const databaseUrl = TitanVault.config.get('DATABASE_URL');
|
|
143
146
|
const port = TitanVault.config.get('PORT', '3000');
|
|
144
147
|
const host = TitanVault.config.get('HOST', '0.0.0.0');
|
|
145
148
|
```
|
|
146
149
|
|
|
147
150
|
---
|
|
148
151
|
|
|
149
|
-
### 6.
|
|
152
|
+
### 6. Reporte de runtime
|
|
153
|
+
|
|
154
|
+
`verifyRuntimeHardening()` dice si el proceso es root, si `NODE_ENV` es `production`, si el límite blando de core dumps es 0 (`/proc/self/limits`) y si el modo paranoia llegó a armarse. No cambia esos límites.
|
|
150
155
|
|
|
151
156
|
```typescript
|
|
152
157
|
TitanVault.verifyRuntimeHardening();
|
|
@@ -154,6 +159,10 @@ TitanVault.verifyRuntimeHardening();
|
|
|
154
159
|
|
|
155
160
|
---
|
|
156
161
|
|
|
157
|
-
##
|
|
162
|
+
## Honeytoken
|
|
163
|
+
|
|
164
|
+
En la bóveda, la trampa está apagada hasta definir `TITAN_INJECT_HONEYTOKEN=1` y `TITAN_HONEYTOKEN_VALUE`. El campo por defecto es `VAULT_EMERGENCY_RECOVERY_TOKEN` (`TITAN_HONEYTOKEN_FIELD` lo cambia en los dos lados).
|
|
165
|
+
|
|
166
|
+
Leer `process.env.VAULT_EMERGENCY_RECOVERY_TOKEN` lanza `ParanoiaBlockedError`. Si el payload traía el cebo, el SDK llama a `GET /sys/recovery` con ese valor en `x-vault-token`. La bóveda sella cuando el header coincide. Sin cebo en el payload, la lectura se bloquea y la bóveda sigue abierta.
|
|
158
167
|
|
|
159
|
-
|
|
168
|
+
`TitanVault.flushTripwire()` espera a que esa petición termine.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { VaultOptions } from '../types';
|
|
2
|
+
/** Same default field the vault injects (`TITAN_HONEYTOKEN_FIELD`). */
|
|
3
|
+
export declare const HONEYTOKEN_FIELD = "VAULT_EMERGENCY_RECOVERY_TOKEN";
|
|
4
|
+
export type SecretClass = 'honeytoken' | 'crypto' | 'database' | 'config';
|
|
5
|
+
/**
|
|
6
|
+
* Classification shared with the vault.
|
|
7
|
+
* `DATABASE_URL` is configuration the app reads. Only password fields go to the JIT store.
|
|
8
|
+
*/
|
|
9
|
+
export declare function classifyKey(key: string, tag: string | undefined, options?: VaultOptions): SecretClass;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HONEYTOKEN_FIELD = void 0;
|
|
4
|
+
exports.classifyKey = classifyKey;
|
|
5
|
+
/** Same default field the vault injects (`TITAN_HONEYTOKEN_FIELD`). */
|
|
6
|
+
exports.HONEYTOKEN_FIELD = 'VAULT_EMERGENCY_RECOVERY_TOKEN';
|
|
7
|
+
// Keep in sync with titan_vault/core.py `_classify_key`.
|
|
8
|
+
const DB_PASSWORD_RE = /(DB_PASS|POSTGRES_PASS|MYSQL_PASS|DB_PASSWORD|POSTGRES_PASSWORD|MYSQL_PASSWORD)/i;
|
|
9
|
+
const CRYPTO_RE = /(JWT|SECRET|KEY|PRIVATE|TOKEN|SALT)/i;
|
|
10
|
+
/**
|
|
11
|
+
* Classification shared with the vault.
|
|
12
|
+
* `DATABASE_URL` is configuration the app reads. Only password fields go to the JIT store.
|
|
13
|
+
*/
|
|
14
|
+
function classifyKey(key, tag, options = {}) {
|
|
15
|
+
const honeyField = options.honeytokenField || exports.HONEYTOKEN_FIELD;
|
|
16
|
+
if (tag === 'honeytoken' || key === honeyField)
|
|
17
|
+
return 'honeytoken';
|
|
18
|
+
if ((options.cryptoKeys || []).includes(key) || tag === 'crypto')
|
|
19
|
+
return 'crypto';
|
|
20
|
+
if ((options.dbKeys || []).includes(key) || tag === 'database' || DB_PASSWORD_RE.test(key)) {
|
|
21
|
+
return 'database';
|
|
22
|
+
}
|
|
23
|
+
if (CRYPTO_RE.test(key))
|
|
24
|
+
return 'crypto';
|
|
25
|
+
return 'config';
|
|
26
|
+
}
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
export declare class CryptoStore {
|
|
3
3
|
private keyObjects;
|
|
4
|
-
private rawFallback;
|
|
5
4
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Store the secret as a KeyObject and drop the temporary byte buffer.
|
|
6
|
+
* The original JavaScript string from JSON.parse is immutable and stays
|
|
7
|
+
* until V8 collects it. This store does not keep a second copy.
|
|
8
8
|
*/
|
|
9
9
|
register(name: string, rawSecret: string): void;
|
|
10
|
-
/**
|
|
11
|
-
* Retrieves the native C++ KeyObject.
|
|
12
|
-
* This object is invisible to JavaScript heap string inspection and safe from RAM dumps.
|
|
13
|
-
*/
|
|
14
10
|
get(name: string): crypto.KeyObject;
|
|
15
11
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* Materialize the secret as a string. That string lives on the V8 heap
|
|
13
|
+
* until it is collected. Prefer `get()` when the library accepts a KeyObject.
|
|
18
14
|
*/
|
|
19
15
|
getRaw(name: string): string;
|
|
20
16
|
has(name: string): boolean;
|
|
@@ -7,49 +7,30 @@ exports.CryptoStore = void 0;
|
|
|
7
7
|
const crypto_1 = __importDefault(require("crypto"));
|
|
8
8
|
class CryptoStore {
|
|
9
9
|
keyObjects = new Map();
|
|
10
|
-
rawFallback = new Map();
|
|
11
10
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Store the secret as a KeyObject and drop the temporary byte buffer.
|
|
12
|
+
* The original JavaScript string from JSON.parse is immutable and stays
|
|
13
|
+
* until V8 collects it. This store does not keep a second copy.
|
|
14
14
|
*/
|
|
15
15
|
register(name, rawSecret) {
|
|
16
16
|
if (!rawSecret)
|
|
17
17
|
return;
|
|
18
|
-
// Allocate buffer for the raw secret
|
|
19
18
|
const buffer = Buffer.from(rawSecret, 'utf-8');
|
|
20
19
|
try {
|
|
21
|
-
// Check if it's PEM formatted private key
|
|
22
20
|
if (rawSecret.includes('-----BEGIN PRIVATE KEY-----') || rawSecret.includes('-----BEGIN RSA PRIVATE KEY-----')) {
|
|
23
|
-
|
|
24
|
-
key: rawSecret,
|
|
25
|
-
format: 'pem',
|
|
26
|
-
});
|
|
27
|
-
this.keyObjects.set(name, privKey);
|
|
21
|
+
this.keyObjects.set(name, crypto_1.default.createPrivateKey({ key: rawSecret, format: 'pem' }));
|
|
28
22
|
}
|
|
29
23
|
else if (rawSecret.includes('-----BEGIN PUBLIC KEY-----')) {
|
|
30
|
-
|
|
31
|
-
key: rawSecret,
|
|
32
|
-
format: 'pem',
|
|
33
|
-
});
|
|
34
|
-
this.keyObjects.set(name, pubKey);
|
|
24
|
+
this.keyObjects.set(name, crypto_1.default.createPublicKey({ key: rawSecret, format: 'pem' }));
|
|
35
25
|
}
|
|
36
26
|
else {
|
|
37
|
-
|
|
38
|
-
const secretKey = crypto_1.default.createSecretKey(buffer);
|
|
39
|
-
this.keyObjects.set(name, secretKey);
|
|
27
|
+
this.keyObjects.set(name, crypto_1.default.createSecretKey(buffer));
|
|
40
28
|
}
|
|
41
29
|
}
|
|
42
30
|
finally {
|
|
43
|
-
// ZERO OUT BUFFER IN MEMORY immediately to prevent RAM heap dump recovery
|
|
44
31
|
buffer.fill(0);
|
|
45
32
|
}
|
|
46
|
-
// Keep an internal raw fallback for libraries that strictly require raw strings (e.g. legacy argon2)
|
|
47
|
-
this.rawFallback.set(name, rawSecret);
|
|
48
33
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Retrieves the native C++ KeyObject.
|
|
51
|
-
* This object is invisible to JavaScript heap string inspection and safe from RAM dumps.
|
|
52
|
-
*/
|
|
53
34
|
get(name) {
|
|
54
35
|
const key = this.keyObjects.get(name);
|
|
55
36
|
if (!key) {
|
|
@@ -58,17 +39,23 @@ class CryptoStore {
|
|
|
58
39
|
return key;
|
|
59
40
|
}
|
|
60
41
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
42
|
+
* Materialize the secret as a string. That string lives on the V8 heap
|
|
43
|
+
* until it is collected. Prefer `get()` when the library accepts a KeyObject.
|
|
63
44
|
*/
|
|
64
45
|
getRaw(name) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
46
|
+
const key = this.get(name);
|
|
47
|
+
console.warn(`⚠️ [Titan Vault] '${name}' se materializó como texto. ` +
|
|
48
|
+
`Ese string queda en el heap de V8 hasta que el recolector lo libere. ` +
|
|
49
|
+
`Usa get() cuando la librería acepte un KeyObject.`);
|
|
50
|
+
if (key.type === 'secret') {
|
|
51
|
+
const exported = key.export();
|
|
52
|
+
return Buffer.isBuffer(exported) ? exported.toString('utf8') : Buffer.from(exported).toString('utf8');
|
|
68
53
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
54
|
+
const exported = key.export({
|
|
55
|
+
type: key.type === 'private' ? 'pkcs8' : 'spki',
|
|
56
|
+
format: 'pem',
|
|
57
|
+
});
|
|
58
|
+
return typeof exported === 'string' ? exported : exported.toString('utf8');
|
|
72
59
|
}
|
|
73
60
|
has(name) {
|
|
74
61
|
return this.keyObjects.has(name);
|
|
@@ -3,17 +3,15 @@ export declare class DatabaseHooks {
|
|
|
3
3
|
register(key: string, value: string): void;
|
|
4
4
|
getJitCredential(key: string): string;
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* and immediately wipes it (sets config.password = null) after connection succeeds,
|
|
9
|
-
* minimizing RAM heap exposure.
|
|
6
|
+
* Inject the password on `beforeConnect` and clear `config.password` after.
|
|
7
|
+
* The driver still receives a JavaScript string for the handshake.
|
|
10
8
|
*/
|
|
11
9
|
hookSequelize(sequelize: any, options?: {
|
|
12
10
|
credentialKey?: string;
|
|
13
11
|
}): void;
|
|
14
12
|
/**
|
|
15
|
-
*
|
|
16
|
-
* pg.Pool supports `password: async () => string` dynamically per socket connection.
|
|
13
|
+
* Password function for `pg.Pool`. node-postgres calls it per connection.
|
|
17
14
|
*/
|
|
18
15
|
getPgPasswordProvider(keyName?: string): () => Promise<string>;
|
|
16
|
+
private materialize;
|
|
19
17
|
}
|
|
@@ -4,22 +4,19 @@ exports.DatabaseHooks = void 0;
|
|
|
4
4
|
class DatabaseHooks {
|
|
5
5
|
credentials = new Map();
|
|
6
6
|
register(key, value) {
|
|
7
|
-
if (key
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
if (!key || !value)
|
|
8
|
+
return;
|
|
9
|
+
const existing = this.credentials.get(key);
|
|
10
|
+
if (existing)
|
|
11
|
+
existing.fill(0);
|
|
12
|
+
this.credentials.set(key, Buffer.from(value, 'utf8'));
|
|
10
13
|
}
|
|
11
14
|
getJitCredential(key) {
|
|
12
|
-
|
|
13
|
-
if (!cred) {
|
|
14
|
-
throw new Error(`[Titan Vault - JIT DB] Credencial '${key}' no encontrada en el almacén JIT.`);
|
|
15
|
-
}
|
|
16
|
-
return cred;
|
|
15
|
+
return this.materialize(key);
|
|
17
16
|
}
|
|
18
17
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* and immediately wipes it (sets config.password = null) after connection succeeds,
|
|
22
|
-
* minimizing RAM heap exposure.
|
|
18
|
+
* Inject the password on `beforeConnect` and clear `config.password` after.
|
|
19
|
+
* The driver still receives a JavaScript string for the handshake.
|
|
23
20
|
*/
|
|
24
21
|
hookSequelize(sequelize, options = {}) {
|
|
25
22
|
const credKey = options.credentialKey || 'DB_PASSWORD';
|
|
@@ -27,43 +24,28 @@ class DatabaseHooks {
|
|
|
27
24
|
console.warn('⚠️ [Titan Vault] La instancia provista no parece ser un objeto Sequelize válido.');
|
|
28
25
|
return;
|
|
29
26
|
}
|
|
30
|
-
// 1. Before connection handshake: Inject credential JIT
|
|
31
27
|
sequelize.addHook('beforeConnect', async (config) => {
|
|
32
|
-
|
|
33
|
-
const password = this.credentials.get(credKey) || this.credentials.get('DATABASE_URL');
|
|
34
|
-
if (password) {
|
|
35
|
-
config.password = password;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
catch (err) {
|
|
39
|
-
console.error('🚨 [Titan Vault] Error inyectando contraseña JIT en Sequelize:', err);
|
|
40
|
-
}
|
|
28
|
+
config.password = this.materialize(credKey);
|
|
41
29
|
});
|
|
42
|
-
// 2. After connection handshake: Nullify immediately to force Garbage Collection
|
|
43
30
|
sequelize.addHook('afterConnect', async (_connection, config) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
config.password = null;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
catch (err) {
|
|
50
|
-
// ignore
|
|
31
|
+
if (config && 'password' in config) {
|
|
32
|
+
config.password = null;
|
|
51
33
|
}
|
|
52
34
|
});
|
|
53
|
-
console.log(`🛡️ [Titan Vault] Hook Sequelize JIT activado
|
|
35
|
+
console.log(`🛡️ [Titan Vault] Hook Sequelize JIT activado para '${credKey}'.`);
|
|
54
36
|
}
|
|
55
37
|
/**
|
|
56
|
-
*
|
|
57
|
-
* pg.Pool supports `password: async () => string` dynamically per socket connection.
|
|
38
|
+
* Password function for `pg.Pool`. node-postgres calls it per connection.
|
|
58
39
|
*/
|
|
59
40
|
getPgPasswordProvider(keyName = 'DB_PASSWORD') {
|
|
60
|
-
return async () =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
41
|
+
return async () => this.materialize(keyName);
|
|
42
|
+
}
|
|
43
|
+
materialize(key) {
|
|
44
|
+
const buf = this.credentials.get(key);
|
|
45
|
+
if (!buf) {
|
|
46
|
+
throw new Error(`[Titan Vault - JIT DB] Credencial '${key}' no encontrada en el almacén JIT.`);
|
|
47
|
+
}
|
|
48
|
+
return buf.toString('utf8');
|
|
67
49
|
}
|
|
68
50
|
}
|
|
69
51
|
exports.DatabaseHooks = DatabaseHooks;
|
package/dist/core/hardening.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { HardeningReport } from '../types';
|
|
2
|
-
export declare function verifyRuntimeHardening(): HardeningReport;
|
|
2
|
+
export declare function verifyRuntimeHardening(paranoiaArmed: boolean): HardeningReport;
|
package/dist/core/hardening.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.verifyRuntimeHardening = verifyRuntimeHardening;
|
|
4
|
-
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
function readCoreDumpSoftLimit() {
|
|
9
|
+
try {
|
|
10
|
+
const text = fs_1.default.readFileSync('/proc/self/limits', 'utf8');
|
|
11
|
+
const line = text.split('\n').find((entry) => entry.toLowerCase().startsWith('max core file size'));
|
|
12
|
+
if (!line)
|
|
13
|
+
return null;
|
|
14
|
+
const cols = line.split(/\s{2,}/).map((part) => part.trim());
|
|
15
|
+
if (!cols[1])
|
|
16
|
+
return null;
|
|
17
|
+
return cols[1] === '0';
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function verifyRuntimeHardening(paranoiaArmed) {
|
|
5
24
|
const recommendations = [];
|
|
6
25
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
7
26
|
let runningAsRoot = false;
|
|
@@ -11,20 +30,30 @@ function verifyRuntimeHardening() {
|
|
|
11
30
|
}
|
|
12
31
|
}
|
|
13
32
|
catch {
|
|
14
|
-
// Windows or restricted environment
|
|
33
|
+
// Windows or a restricted environment.
|
|
15
34
|
}
|
|
35
|
+
const coreDumpsDisabled = readCoreDumpSoftLimit();
|
|
16
36
|
if (runningAsRoot) {
|
|
17
|
-
recommendations.push('
|
|
37
|
+
recommendations.push('El proceso corre como root (UID 0). En producción usa un usuario sin privilegios.');
|
|
18
38
|
}
|
|
19
39
|
if (nodeEnv !== 'production') {
|
|
20
|
-
recommendations.push('
|
|
40
|
+
recommendations.push('NODE_ENV no es "production".');
|
|
41
|
+
}
|
|
42
|
+
if (coreDumpsDisabled === null) {
|
|
43
|
+
recommendations.push('No se pudo leer el límite de core dumps. En Linux revisa /proc/self/limits (Max core file size).');
|
|
44
|
+
}
|
|
45
|
+
else if (!coreDumpsDisabled) {
|
|
46
|
+
recommendations.push('Los core dumps están permitidos. Un ulimit -c 0 evita que un volcado escriba la memoria a disco.');
|
|
47
|
+
}
|
|
48
|
+
if (!paranoiaArmed) {
|
|
49
|
+
recommendations.push('El modo paranoia no está armado. Los secretos no pasaron por el proxy de process.env.');
|
|
21
50
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
console.log('\n🛡️ [Titan Vault] Reporte de Hardening en Runtime:');
|
|
51
|
+
const isHardened = !runningAsRoot && nodeEnv === 'production' && coreDumpsDisabled === true && paranoiaArmed;
|
|
52
|
+
console.log('\n🛡️ [Titan Vault] Reporte de hardening:');
|
|
25
53
|
console.log(` - Entorno: ${nodeEnv}`);
|
|
26
|
-
console.log(` - Usuario
|
|
27
|
-
console.log(` -
|
|
54
|
+
console.log(` - Usuario root: ${runningAsRoot ? 'sí' : 'no'}`);
|
|
55
|
+
console.log(` - Core dumps desactivados: ${coreDumpsDisabled === null ? 'desconocido' : coreDumpsDisabled ? 'sí' : 'no'}`);
|
|
56
|
+
console.log(` - Modo paranoia armado: ${paranoiaArmed ? 'sí' : 'no'}`);
|
|
28
57
|
for (const rec of recommendations) {
|
|
29
58
|
console.log(` ${rec}`);
|
|
30
59
|
}
|
|
@@ -32,8 +61,10 @@ function verifyRuntimeHardening() {
|
|
|
32
61
|
return {
|
|
33
62
|
isHardened,
|
|
34
63
|
nodeEnv,
|
|
35
|
-
coreDumpsDisabled: true,
|
|
64
|
+
coreDumpsDisabled: coreDumpsDisabled === true,
|
|
65
|
+
coreDumpLimitKnown: coreDumpsDisabled !== null,
|
|
36
66
|
runningAsRoot,
|
|
67
|
+
paranoiaArmed,
|
|
37
68
|
recommendations,
|
|
38
69
|
};
|
|
39
70
|
}
|
package/dist/core/poison.js
CHANGED
|
@@ -50,22 +50,22 @@ function poisonProcessEnv(secretKeys, honeytokenKey, onHoneytokenDetonated) {
|
|
|
50
50
|
return Reflect.get(target, prop, receiver);
|
|
51
51
|
},
|
|
52
52
|
set(target, prop, value, receiver) {
|
|
53
|
-
if (typeof prop === 'string' && poisonedSet.has(prop)) {
|
|
53
|
+
if (typeof prop === 'string' && (prop === honeyKey || poisonedSet.has(prop))) {
|
|
54
54
|
throw new ParanoiaBlockedError(prop, `No se permite mutar 'process.env.${prop}' en Modo Paranoia.`);
|
|
55
55
|
}
|
|
56
56
|
return Reflect.set(target, prop, value, receiver);
|
|
57
57
|
},
|
|
58
58
|
has(target, prop) {
|
|
59
|
-
if (typeof prop === 'string' && poisonedSet.has(prop)) {
|
|
59
|
+
if (typeof prop === 'string' && (prop === honeyKey || poisonedSet.has(prop))) {
|
|
60
60
|
return false;
|
|
61
61
|
}
|
|
62
62
|
return Reflect.has(target, prop);
|
|
63
63
|
},
|
|
64
64
|
ownKeys(target) {
|
|
65
|
-
return Reflect.ownKeys(target).filter(k => typeof k !== 'string' || !poisonedSet.has(k));
|
|
65
|
+
return Reflect.ownKeys(target).filter((k) => typeof k !== 'string' || (k !== honeyKey && !poisonedSet.has(k)));
|
|
66
66
|
},
|
|
67
67
|
getOwnPropertyDescriptor(target, prop) {
|
|
68
|
-
if (typeof prop === 'string' && poisonedSet.has(prop)) {
|
|
68
|
+
if (typeof prop === 'string' && (prop === honeyKey || poisonedSet.has(prop))) {
|
|
69
69
|
return undefined;
|
|
70
70
|
}
|
|
71
71
|
return Reflect.getOwnPropertyDescriptor(target, prop);
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export * from './core/crypto-store';
|
|
|
8
8
|
export * from './core/database-hooks';
|
|
9
9
|
export * from './core/transit-client';
|
|
10
10
|
export * from './core/hardening';
|
|
11
|
+
export * from './core/classify';
|
|
12
|
+
export declare class VaultConnectionError extends Error {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
11
15
|
export declare class TitanVault {
|
|
12
16
|
private static instance;
|
|
13
17
|
static readonly secrets: CryptoStore;
|
|
@@ -16,6 +20,8 @@ export declare class TitanVault {
|
|
|
16
20
|
private static vaultUrl;
|
|
17
21
|
private static vaultToken?;
|
|
18
22
|
private static isArmed;
|
|
23
|
+
private static baitValues;
|
|
24
|
+
private static tripwireInflight;
|
|
19
25
|
static readonly transit: TransitClient;
|
|
20
26
|
static readonly config: {
|
|
21
27
|
get(key: string, defaultValue?: string): string;
|
|
@@ -23,24 +29,19 @@ export declare class TitanVault {
|
|
|
23
29
|
getAll(): Record<string, string>;
|
|
24
30
|
};
|
|
25
31
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* 3. Isolates database passwords into JIT hooks.
|
|
30
|
-
* 4. Envenoms process.env for all secret keys, throwing on unauthorized reads.
|
|
31
|
-
* 5. Arms Honeytoken Tripwire alarms.
|
|
32
|
+
* Fetch the app path with an ephemeral RSA-2048 key, decrypt the hybrid
|
|
33
|
+
* payload, and block secret reads on process.env.
|
|
34
|
+
* Throws if the token is missing or the vault does not return ciphertext.
|
|
32
35
|
*/
|
|
33
36
|
static armParanoiaMode(options?: VaultOptions): Promise<void>;
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
* Initializes Paranoia Mode asynchronously, and once memory is hardened,
|
|
37
|
-
* dynamically executes the app server import.
|
|
38
|
-
*/
|
|
37
|
+
/** Resolves when the in-flight honeytoken alert has finished or failed. */
|
|
38
|
+
static flushTripwire(): Promise<void>;
|
|
39
39
|
static bootstrap(serverFn: () => Promise<any>, options?: VaultOptions): Promise<void>;
|
|
40
40
|
static verifyRuntimeHardening(): HardeningReport;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* Classic mode (`paranoiaMode: false`) copies string values into process.env.
|
|
44
|
+
* The default calls armParanoiaMode and throws if the vault is unreachable.
|
|
44
45
|
*/
|
|
45
46
|
export declare function initVault(options?: VaultOptions): Promise<void>;
|
|
46
47
|
export default TitanVault;
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
17
17
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
18
|
};
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
-
exports.TitanVault = void 0;
|
|
20
|
+
exports.TitanVault = exports.VaultConnectionError = void 0;
|
|
21
21
|
exports.initVault = initVault;
|
|
22
22
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
23
23
|
const crypto_1 = __importDefault(require("crypto"));
|
|
@@ -26,12 +26,55 @@ const crypto_store_1 = require("./core/crypto-store");
|
|
|
26
26
|
const database_hooks_1 = require("./core/database-hooks");
|
|
27
27
|
const transit_client_1 = require("./core/transit-client");
|
|
28
28
|
const hardening_1 = require("./core/hardening");
|
|
29
|
+
const classify_1 = require("./core/classify");
|
|
29
30
|
__exportStar(require("./types"), exports);
|
|
30
31
|
__exportStar(require("./core/poison"), exports);
|
|
31
32
|
__exportStar(require("./core/crypto-store"), exports);
|
|
32
33
|
__exportStar(require("./core/database-hooks"), exports);
|
|
33
34
|
__exportStar(require("./core/transit-client"), exports);
|
|
34
35
|
__exportStar(require("./core/hardening"), exports);
|
|
36
|
+
__exportStar(require("./core/classify"), exports);
|
|
37
|
+
class VaultConnectionError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'VaultConnectionError';
|
|
41
|
+
Object.setPrototypeOf(this, VaultConnectionError.prototype);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.VaultConnectionError = VaultConnectionError;
|
|
45
|
+
function resolveToken(explicit) {
|
|
46
|
+
return explicit || process.env.VAULT_TOKEN || process.env.VAULT_APP_TOKEN;
|
|
47
|
+
}
|
|
48
|
+
function resolveUrl(explicit) {
|
|
49
|
+
return explicit || process.env.VAULT_URL || 'http://127.0.0.1:8200';
|
|
50
|
+
}
|
|
51
|
+
function decryptHybrid(privateKey, payloadB64) {
|
|
52
|
+
const buffer = Buffer.from(payloadB64, 'base64');
|
|
53
|
+
const modulusLength = privateKey.asymmetricKeyDetails?.modulusLength;
|
|
54
|
+
if (!modulusLength) {
|
|
55
|
+
throw new VaultConnectionError('No se pudo leer el tamaño de la llave RSA efímera.');
|
|
56
|
+
}
|
|
57
|
+
const keyBytes = modulusLength / 8;
|
|
58
|
+
if (buffer.length < keyBytes + 16) {
|
|
59
|
+
throw new VaultConnectionError('El payload cifrado es más corto que una respuesta RSA-OAEP + AES-256-CFB.');
|
|
60
|
+
}
|
|
61
|
+
const encAesKey = buffer.subarray(0, keyBytes);
|
|
62
|
+
const iv = buffer.subarray(keyBytes, keyBytes + 16);
|
|
63
|
+
const ciphertext = buffer.subarray(keyBytes + 16);
|
|
64
|
+
const aesKey = crypto_1.default.privateDecrypt({
|
|
65
|
+
key: privateKey,
|
|
66
|
+
padding: crypto_1.default.constants.RSA_PKCS1_OAEP_PADDING,
|
|
67
|
+
oaepHash: 'sha256',
|
|
68
|
+
}, encAesKey);
|
|
69
|
+
try {
|
|
70
|
+
const decipher = crypto_1.default.createDecipheriv('aes-256-cfb', aesKey, iv);
|
|
71
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
72
|
+
return decrypted.toString('utf8');
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
aesKey.fill(0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
35
78
|
class TitanVault {
|
|
36
79
|
static instance;
|
|
37
80
|
static secrets = new crypto_store_1.CryptoStore();
|
|
@@ -40,12 +83,26 @@ class TitanVault {
|
|
|
40
83
|
static vaultUrl = 'http://127.0.0.1:8200';
|
|
41
84
|
static vaultToken;
|
|
42
85
|
static isArmed = false;
|
|
86
|
+
static baitValues = new Map();
|
|
87
|
+
static tripwireInflight = Promise.resolve();
|
|
43
88
|
static transit = new transit_client_1.TransitClient(() => TitanVault.vaultUrl, () => TitanVault.vaultToken);
|
|
44
89
|
static config = {
|
|
45
90
|
get(key, defaultValue) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
91
|
+
if (TitanVault.rawConfig.has(key)) {
|
|
92
|
+
return TitanVault.rawConfig.get(key);
|
|
93
|
+
}
|
|
94
|
+
let envVal;
|
|
95
|
+
try {
|
|
96
|
+
envVal = process.env[key];
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err instanceof Error && err.name === 'ParanoiaBlockedError') {
|
|
100
|
+
throw new Error(`[Titan Vault] '${key}' es un secreto. Usa TitanVault.secrets, TitanVault.databases o el campo de configuración correspondiente.`);
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
if (envVal !== undefined)
|
|
105
|
+
return envVal;
|
|
49
106
|
if (defaultValue !== undefined)
|
|
50
107
|
return defaultValue;
|
|
51
108
|
throw new Error(`[Titan Vault] Configuración '${key}' no encontrada.`);
|
|
@@ -62,126 +119,95 @@ class TitanVault {
|
|
|
62
119
|
}
|
|
63
120
|
};
|
|
64
121
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* 3. Isolates database passwords into JIT hooks.
|
|
69
|
-
* 4. Envenoms process.env for all secret keys, throwing on unauthorized reads.
|
|
70
|
-
* 5. Arms Honeytoken Tripwire alarms.
|
|
122
|
+
* Fetch the app path with an ephemeral RSA-2048 key, decrypt the hybrid
|
|
123
|
+
* payload, and block secret reads on process.env.
|
|
124
|
+
* Throws if the token is missing or the vault does not return ciphertext.
|
|
71
125
|
*/
|
|
72
126
|
static async armParanoiaMode(options = {}) {
|
|
73
127
|
if (this.isArmed) {
|
|
74
|
-
console.log('🛡️ [Titan Vault] Modo
|
|
128
|
+
console.log('🛡️ [Titan Vault] Modo paranoia ya está armado.');
|
|
75
129
|
return;
|
|
76
130
|
}
|
|
77
131
|
dotenv_1.default.config();
|
|
78
|
-
this.vaultUrl = options.vaultUrl
|
|
79
|
-
this.vaultToken = options.vaultToken
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
132
|
+
this.vaultUrl = resolveUrl(options.vaultUrl);
|
|
133
|
+
this.vaultToken = resolveToken(options.vaultToken);
|
|
134
|
+
const secretPath = options.path || 'backend_env';
|
|
135
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
136
|
+
const retryDelay = options.retryDelayMs ?? 5000;
|
|
137
|
+
const honeyField = options.honeytokenField || classify_1.HONEYTOKEN_FIELD;
|
|
83
138
|
if (!this.vaultToken) {
|
|
84
|
-
|
|
85
|
-
return;
|
|
139
|
+
throw new VaultConnectionError('Falta el token. Pasa vaultToken o define VAULT_TOKEN / VAULT_APP_TOKEN. El proceso no arranca sin la bóveda.');
|
|
86
140
|
}
|
|
87
|
-
console.log(`\n🛡️ [Titan Vault
|
|
88
|
-
// Ephemeral RSA keypair for E2E zero-trust transit
|
|
141
|
+
console.log(`\n🛡️ [Titan Vault] Conectando a ${this.vaultUrl}...`);
|
|
89
142
|
const { publicKey, privateKey } = crypto_1.default.generateKeyPairSync('rsa', {
|
|
90
143
|
modulusLength: 2048,
|
|
91
|
-
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
92
|
-
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
93
144
|
});
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
let rawSecrets =
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
const pubPem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
146
|
+
const pubBase64 = Buffer.from(pubPem).toString('base64');
|
|
147
|
+
let rawSecrets = null;
|
|
148
|
+
let lastError = 'sin respuesta';
|
|
149
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
99
150
|
try {
|
|
100
|
-
const res = await fetch(`${this.vaultUrl}/secret/data/${
|
|
151
|
+
const res = await fetch(`${this.vaultUrl}/secret/data/${secretPath}`, {
|
|
101
152
|
headers: {
|
|
102
153
|
'x-vault-token': this.vaultToken,
|
|
103
154
|
'x-vault-public-key': pubBase64,
|
|
104
155
|
},
|
|
105
156
|
});
|
|
106
|
-
if (res.ok) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (json.data && json.data.is_encrypted) {
|
|
110
|
-
console.log('🔐 [Titan Vault] Descifrando payload E2E híbrido (RSA-OAEP-2048 + AES-256-CFB)...');
|
|
111
|
-
const buffer = Buffer.from(json.data.value, 'base64');
|
|
112
|
-
const encAesKey = buffer.subarray(0, 256);
|
|
113
|
-
const iv = buffer.subarray(256, 256 + 16);
|
|
114
|
-
const ciphertext = buffer.subarray(256 + 16);
|
|
115
|
-
const aesKey = crypto_1.default.privateDecrypt({
|
|
116
|
-
key: privateKey,
|
|
117
|
-
padding: crypto_1.default.constants.RSA_PKCS1_OAEP_PADDING,
|
|
118
|
-
oaepHash: 'sha256',
|
|
119
|
-
}, encAesKey);
|
|
120
|
-
const decipher = crypto_1.default.createDecipheriv('aes-256-cfb', aesKey, iv);
|
|
121
|
-
let decrypted = decipher.update(ciphertext, undefined, 'utf8');
|
|
122
|
-
decrypted += decipher.final('utf8');
|
|
123
|
-
finalSecretStr = decrypted;
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
finalSecretStr = json.data?.value || '';
|
|
127
|
-
}
|
|
128
|
-
rawSecrets = JSON.parse(finalSecretStr);
|
|
129
|
-
break; // Success!
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
lastError = `HTTP ${res.status}: ${await res.text()}`;
|
|
159
|
+
console.warn(`[Titan Vault] ${lastError}. Reintento ${attempt}/${maxRetries}.`);
|
|
130
160
|
}
|
|
131
161
|
else {
|
|
132
|
-
const
|
|
133
|
-
|
|
162
|
+
const json = await res.json();
|
|
163
|
+
if (!json.data?.is_encrypted || typeof json.data.value !== 'string') {
|
|
164
|
+
throw new VaultConnectionError('La bóveda devolvió el secreto en claro después de recibir x-vault-public-key.');
|
|
165
|
+
}
|
|
166
|
+
console.log('🔐 [Titan Vault] Descifrando payload híbrido (RSA-OAEP-SHA256 + AES-256-CFB)...');
|
|
167
|
+
rawSecrets = JSON.parse(decryptHybrid(privateKey, json.data.value));
|
|
168
|
+
break;
|
|
134
169
|
}
|
|
135
170
|
}
|
|
136
171
|
catch (err) {
|
|
137
|
-
|
|
172
|
+
if (err instanceof VaultConnectionError)
|
|
173
|
+
throw err;
|
|
174
|
+
lastError = err?.message || String(err);
|
|
175
|
+
console.warn(`❌ [Titan Vault] ${lastError}. Reintento ${attempt}/${maxRetries}.`);
|
|
138
176
|
}
|
|
139
|
-
if (attempt <
|
|
140
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
177
|
+
if (attempt < maxRetries) {
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
141
179
|
}
|
|
142
180
|
}
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
return;
|
|
181
|
+
if (!rawSecrets) {
|
|
182
|
+
throw new VaultConnectionError(`No se pudieron leer los secretos de '${secretPath}' en ${this.vaultUrl}. ${lastError}`);
|
|
146
183
|
}
|
|
147
|
-
// Unpack classification or run auto-classification
|
|
148
184
|
const classification = rawSecrets['__titan_classification__'] || {};
|
|
149
185
|
delete rawSecrets['__titan_classification__'];
|
|
150
|
-
const cryptoKeyList = options.cryptoKeys || [];
|
|
151
|
-
const dbKeyList = options.dbKeys || [];
|
|
152
186
|
const allowedEnvList = new Set(options.allowedEnvKeys || ['NODE_ENV', 'PORT', 'HOST', 'FRONTEND_PORT', 'BACKEND_PORT']);
|
|
153
187
|
const poisonedKeys = [];
|
|
188
|
+
this.baitValues = new Map();
|
|
154
189
|
let cryptoCount = 0;
|
|
155
190
|
let dbCount = 0;
|
|
156
191
|
let configCount = 0;
|
|
157
192
|
for (const [key, value] of Object.entries(rawSecrets)) {
|
|
158
193
|
if (typeof value !== 'string')
|
|
159
194
|
continue;
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
(!dbKeyList.includes(key) && /(JWT|SECRET|KEY|PRIVATE|TOKEN|SALT)/i.test(key));
|
|
164
|
-
const isDb = dbKeyList.includes(key) ||
|
|
165
|
-
tag === 'database' ||
|
|
166
|
-
/(DB_PASS|DATABASE_URL|POSTGRES_PASS|MYSQL_PASS|DB_PASSWORD)/i.test(key);
|
|
167
|
-
const isHoney = tag === 'honeytoken' || key === 'VAULT_EMERGENCY_RECOVERY_TOKEN';
|
|
168
|
-
if (isHoney) {
|
|
195
|
+
const kind = (0, classify_1.classifyKey)(key, classification[key], options);
|
|
196
|
+
if (kind === 'honeytoken') {
|
|
197
|
+
this.baitValues.set(key, value);
|
|
169
198
|
poisonedKeys.push(key);
|
|
170
199
|
}
|
|
171
|
-
else if (
|
|
172
|
-
// Securely store in C++ KeyObject store & poison process.env
|
|
200
|
+
else if (kind === 'crypto') {
|
|
173
201
|
this.secrets.register(key, value);
|
|
174
202
|
poisonedKeys.push(key);
|
|
175
203
|
cryptoCount++;
|
|
176
204
|
}
|
|
177
|
-
else if (
|
|
178
|
-
// Securely store in JIT DB store & poison process.env
|
|
205
|
+
else if (kind === 'database') {
|
|
179
206
|
this.databases.register(key, value);
|
|
180
207
|
poisonedKeys.push(key);
|
|
181
208
|
dbCount++;
|
|
182
209
|
}
|
|
183
210
|
else {
|
|
184
|
-
// General configuration
|
|
185
211
|
this.rawConfig.set(key, value);
|
|
186
212
|
if (allowedEnvList.has(key)) {
|
|
187
213
|
process.env[key] = value;
|
|
@@ -189,79 +215,78 @@ class TitanVault {
|
|
|
189
215
|
configCount++;
|
|
190
216
|
}
|
|
191
217
|
}
|
|
192
|
-
|
|
218
|
+
if (!poisonedKeys.includes(honeyField)) {
|
|
219
|
+
poisonedKeys.push(honeyField);
|
|
220
|
+
}
|
|
193
221
|
const onDetonate = (tokenKey) => {
|
|
194
|
-
console.error(`\n
|
|
195
|
-
|
|
196
|
-
if (
|
|
197
|
-
fetch(`${this.vaultUrl}/sys/recovery`, {
|
|
198
|
-
headers: { 'x-vault-token':
|
|
199
|
-
}).
|
|
200
|
-
}
|
|
201
|
-
if (options.onHoneytokenDetonated) {
|
|
202
|
-
options.onHoneytokenDetonated(tokenKey);
|
|
222
|
+
console.error(`\n🚨 [Titan Vault] Honeytoken '${tokenKey}' leído desde process.env.`);
|
|
223
|
+
const bait = this.baitValues.get(tokenKey);
|
|
224
|
+
if (bait) {
|
|
225
|
+
this.tripwireInflight = fetch(`${this.vaultUrl}/sys/recovery`, {
|
|
226
|
+
headers: { 'x-vault-token': bait },
|
|
227
|
+
}).then(() => undefined, () => undefined);
|
|
203
228
|
}
|
|
229
|
+
options.onHoneytokenDetonated?.(tokenKey);
|
|
204
230
|
};
|
|
205
|
-
|
|
206
|
-
(0, poison_1.poisonProcessEnv)(poisonedKeys, 'VAULT_EMERGENCY_RECOVERY_TOKEN', onDetonate);
|
|
231
|
+
(0, poison_1.poisonProcessEnv)(poisonedKeys, honeyField, onDetonate);
|
|
207
232
|
this.isArmed = true;
|
|
208
|
-
console.log(
|
|
209
|
-
console.log(` -
|
|
210
|
-
console.log(` -
|
|
211
|
-
console.log(` -
|
|
212
|
-
console.log(` -
|
|
213
|
-
console.log(
|
|
233
|
+
console.log('🛡️ [Titan Vault] Modo paranoia armado:');
|
|
234
|
+
console.log(` - ${cryptoCount} llaves en KeyObject (sin copia string en el SDK)`);
|
|
235
|
+
console.log(` - ${dbCount} contraseñas de base de datos para hooks JIT`);
|
|
236
|
+
console.log(` - ${configCount} valores de configuración, fuera de process.env salvo la lista permitida`);
|
|
237
|
+
console.log(` - ${poisonedKeys.length} nombres bloqueados en process.env`);
|
|
238
|
+
console.log(this.baitValues.size
|
|
239
|
+
? ' - trampa armada: leerla llama a GET /sys/recovery con el valor inyectado por la bóveda'
|
|
240
|
+
: ' - trampa local activa, sin cebo en el payload (la bóveda no se sella)');
|
|
241
|
+
}
|
|
242
|
+
/** Resolves when the in-flight honeytoken alert has finished or failed. */
|
|
243
|
+
static flushTripwire() {
|
|
244
|
+
return this.tripwireInflight;
|
|
214
245
|
}
|
|
215
|
-
/**
|
|
216
|
-
* The 10-line Bootstrap Runner:
|
|
217
|
-
* Initializes Paranoia Mode asynchronously, and once memory is hardened,
|
|
218
|
-
* dynamically executes the app server import.
|
|
219
|
-
*/
|
|
220
246
|
static async bootstrap(serverFn, options = {}) {
|
|
221
247
|
await this.armParanoiaMode(options);
|
|
222
|
-
console.log('🚀 [Titan Vault]
|
|
248
|
+
console.log('🚀 [Titan Vault] Secretos cargados. Arrancando servidor...');
|
|
223
249
|
await serverFn();
|
|
224
250
|
}
|
|
225
251
|
static verifyRuntimeHardening() {
|
|
226
|
-
return (0, hardening_1.verifyRuntimeHardening)();
|
|
252
|
+
return (0, hardening_1.verifyRuntimeHardening)(this.isArmed);
|
|
227
253
|
}
|
|
228
254
|
}
|
|
229
255
|
exports.TitanVault = TitanVault;
|
|
230
256
|
/**
|
|
231
|
-
*
|
|
257
|
+
* Classic mode (`paranoiaMode: false`) copies string values into process.env.
|
|
258
|
+
* The default calls armParanoiaMode and throws if the vault is unreachable.
|
|
232
259
|
*/
|
|
233
260
|
async function initVault(options = {}) {
|
|
234
261
|
if (options.paranoiaMode !== false) {
|
|
235
262
|
await TitanVault.armParanoiaMode(options);
|
|
263
|
+
return;
|
|
236
264
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
catch (err) {
|
|
263
|
-
console.error(`❌ [Titan Vault] Error: ${err.message}`);
|
|
264
|
-
}
|
|
265
|
+
dotenv_1.default.config();
|
|
266
|
+
const vaultUrl = resolveUrl(options.vaultUrl);
|
|
267
|
+
const vaultToken = resolveToken(options.vaultToken);
|
|
268
|
+
const secretPath = options.path || 'backend_env';
|
|
269
|
+
const honeyField = options.honeytokenField || classify_1.HONEYTOKEN_FIELD;
|
|
270
|
+
if (!vaultToken) {
|
|
271
|
+
console.warn('⚠️ [Titan Vault] Sin token. Se mantienen las variables de .env.');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const res = await fetch(`${vaultUrl}/secret/data/${secretPath}`, {
|
|
275
|
+
headers: { 'x-vault-token': vaultToken },
|
|
276
|
+
});
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
throw new VaultConnectionError(`Modo clásico: la bóveda respondió HTTP ${res.status} al leer '${secretPath}'.`);
|
|
279
|
+
}
|
|
280
|
+
const json = await res.json();
|
|
281
|
+
const secrets = typeof json.data?.value === 'string' ? JSON.parse(json.data.value) : json.data?.value || {};
|
|
282
|
+
const classification = secrets['__titan_classification__'] || {};
|
|
283
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
284
|
+
if (key === '__titan_classification__' || typeof value !== 'string')
|
|
285
|
+
continue;
|
|
286
|
+
if ((0, classify_1.classifyKey)(key, classification[key], options) === 'honeytoken' || key === honeyField)
|
|
287
|
+
continue;
|
|
288
|
+
process.env[key] = value;
|
|
265
289
|
}
|
|
290
|
+
console.log('✅ [Titan Vault] Secretos copiados a process.env (modo clásico).');
|
|
266
291
|
}
|
|
267
292
|
exports.default = TitanVault;
|
package/dist/types.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface VaultOptions {
|
|
|
22
22
|
* Keys that are safe to expose in process.env (e.g. PORT, NODE_ENV).
|
|
23
23
|
*/
|
|
24
24
|
allowedEnvKeys?: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Field the vault injects when `TITAN_HONEYTOKEN_FIELD` is set.
|
|
27
|
+
* Defaults to `VAULT_EMERGENCY_RECOVERY_TOKEN`.
|
|
28
|
+
*/
|
|
29
|
+
honeytokenField?: string;
|
|
25
30
|
/**
|
|
26
31
|
* Custom callback triggered if a honeytoken is accessed in process.env.
|
|
27
32
|
*/
|
|
@@ -52,7 +57,11 @@ export interface TransitVerifyResult {
|
|
|
52
57
|
export interface HardeningReport {
|
|
53
58
|
isHardened: boolean;
|
|
54
59
|
nodeEnv: string;
|
|
60
|
+
/** True only when the soft core limit was read and is 0. */
|
|
55
61
|
coreDumpsDisabled: boolean;
|
|
62
|
+
/** False when `/proc/self/limits` could not be read. */
|
|
63
|
+
coreDumpLimitKnown: boolean;
|
|
56
64
|
runningAsRoot: boolean;
|
|
65
|
+
paranoiaArmed: boolean;
|
|
57
66
|
recommendations: string[];
|
|
58
67
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "titan-vault-node",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Client for Titan Vault. Decrypts the hybrid secret payload, blocks secret reads on process.env, and calls transit on the vault.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
],
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc",
|
|
12
|
-
"prepare": "npm run build"
|
|
12
|
+
"prepare": "npm run build",
|
|
13
|
+
"test": "npm run build && node --test test/harmony.test.mjs"
|
|
13
14
|
},
|
|
14
15
|
"keywords": [
|
|
15
16
|
"vault",
|