3.14 Password Vault encryption AES-256-GCM server-side

- Created src/core/crypto/ with AES-256-GCM encrypt/decrypt (PBKDF2 key derivation)
- Created /api/vault route: CRUD with server-side password encryption
- PATCH /api/vault migration endpoint to re-encrypt legacy plaintext passwords
- Rewrote use-vault hook to use dedicated /api/vault instead of generic storage
- Updated UI: amber 'not encrypted' warning  green 'encrypted' badge
- Added ENCRYPTION_SECRET env var to docker-compose.yml and stack.env
- Module version bumped to 0.2.0
This commit is contained in:
AI Assistant
2026-02-28 04:12:44 +02:00
parent f0b3659247
commit 85bdb59da4
10 changed files with 366 additions and 59 deletions
+83
View File
@@ -0,0 +1,83 @@
/**
* Server-side encryption service using AES-256-GCM.
* Only import this in server-side code (API routes).
*
* Format: enc:v1:<iv-hex>:<authTag-hex>:<ciphertext-hex>
*/
import {
createCipheriv,
createDecipheriv,
randomBytes,
pbkdf2Sync,
} from "crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; // 96 bits recommended for GCM
const TAG_LENGTH = 16; // 128 bits
const PREFIX = "enc:v1:";
/** Derive a 256-bit key from the secret using PBKDF2 */
function deriveKey(secret: string): Buffer {
// Fixed salt — acceptable because the secret itself is high-entropy
const salt = Buffer.from("architools-vault-v1", "utf8");
return pbkdf2Sync(secret, salt, 100_000, 32, "sha256");
}
function getKey(): Buffer {
const secret = process.env.ENCRYPTION_SECRET;
if (!secret || secret.length < 16) {
throw new Error(
"ENCRYPTION_SECRET environment variable must be set (min 16 chars)",
);
}
return deriveKey(secret);
}
/** Encrypt a plaintext string. Returns prefixed format. */
export function encrypt(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: TAG_LENGTH,
});
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
return `${PREFIX}${iv.toString("hex")}:${authTag}:${encrypted}`;
}
/** Decrypt a previously encrypted string. Handles both encrypted and plaintext (migration). */
export function decrypt(value: string): string {
// If not encrypted (legacy plaintext), return as-is
if (!value.startsWith(PREFIX)) {
return value;
}
const key = getKey();
const parts = value.slice(PREFIX.length).split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted format");
}
const [ivHex, authTagHex, ciphertext] = parts;
if (!ivHex || !authTagHex || !ciphertext) {
throw new Error("Invalid encrypted format: missing parts");
}
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = createDecipheriv(ALGORITHM, key, iv, {
authTagLength: TAG_LENGTH,
});
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
/** Check if a value is already encrypted */
export function isEncrypted(value: string): boolean {
return value.startsWith(PREFIX);
}