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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user