103 lines
2.8 KiB
TypeScript
103 lines
2.8 KiB
TypeScript
import crypto from "crypto"
|
|
|
|
const ALGORITHM = "aes-256-gcm"
|
|
const IV_LENGTH = 12 // 96-bit nonce is recommended for GCM
|
|
const AUTH_TAG_LENGTH = 16
|
|
|
|
/**
|
|
* Returns a 32-byte encryption key derived from the MFA_ENCRYPTION_KEY env var.
|
|
* Throws if the key is missing or too weak.
|
|
*/
|
|
function getKey(): Buffer {
|
|
const raw = process.env.MFA_ENCRYPTION_KEY
|
|
|
|
if (!raw) {
|
|
throw new Error("MFA_ENCRYPTION_KEY is not set")
|
|
}
|
|
|
|
// Derive a 32-byte key from the provided secret using SHA-256
|
|
return crypto.createHash("sha256").update(raw, "utf8").digest()
|
|
}
|
|
|
|
export interface EncryptedPayload {
|
|
v: number
|
|
iv: string
|
|
tag: string
|
|
data: string
|
|
}
|
|
|
|
/**
|
|
* Encrypts a UTF-8 string value using AES-256-GCM.
|
|
* Returns a compact JSON string that can be stored in the DB.
|
|
*/
|
|
export function encryptString(plaintext: string): string {
|
|
const key = getKey()
|
|
const iv = crypto.randomBytes(IV_LENGTH)
|
|
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
|
|
authTagLength: AUTH_TAG_LENGTH,
|
|
})
|
|
|
|
const encrypted = Buffer.concat([
|
|
cipher.update(plaintext, "utf8"),
|
|
cipher.final(),
|
|
])
|
|
const tag = cipher.getAuthTag()
|
|
|
|
const payload: EncryptedPayload = {
|
|
v: 1,
|
|
iv: iv.toString("base64"),
|
|
tag: tag.toString("base64"),
|
|
data: encrypted.toString("base64"),
|
|
}
|
|
|
|
return JSON.stringify(payload)
|
|
}
|
|
|
|
/**
|
|
* Decrypts a value previously produced by encryptString.
|
|
* For backward compatibility, if the input is not a valid payload,
|
|
* it will be returned as-is (assuming it is plaintext).
|
|
*/
|
|
export function decryptString(value: string | null): string | null {
|
|
if (!value) return null
|
|
|
|
try {
|
|
const parsed = JSON.parse(value) as EncryptedPayload
|
|
|
|
if (
|
|
typeof parsed !== "object" ||
|
|
parsed === null ||
|
|
parsed.v !== 1 ||
|
|
typeof parsed.iv !== "string" ||
|
|
typeof parsed.tag !== "string" ||
|
|
typeof parsed.data !== "string"
|
|
) {
|
|
// Not our expected structure; treat as plaintext
|
|
return value
|
|
}
|
|
|
|
const key = getKey()
|
|
const iv = Buffer.from(parsed.iv, "base64")
|
|
const tag = Buffer.from(parsed.tag, "base64")
|
|
const encrypted = Buffer.from(parsed.data, "base64")
|
|
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
|
|
authTagLength: AUTH_TAG_LENGTH,
|
|
})
|
|
decipher.setAuthTag(tag)
|
|
|
|
const decrypted = Buffer.concat([
|
|
decipher.update(encrypted),
|
|
decipher.final(),
|
|
])
|
|
|
|
return decrypted.toString("utf8")
|
|
} catch {
|
|
// If anything goes wrong (invalid JSON, missing env, crypto error),
|
|
// fall back to returning the original value to avoid locking users out.
|
|
return value
|
|
}
|
|
}
|
|
|