116 lines
3.0 KiB
TypeScript
116 lines
3.0 KiB
TypeScript
import crypto from "node:crypto";
|
|
|
|
const PBKDF2_ITERATIONS = 100_000;
|
|
const PBKDF2_KEYLEN = 32;
|
|
const PBKDF2_DIGEST = "sha512";
|
|
const SALT_BYTES = 32;
|
|
|
|
/**
|
|
* Hash a password using pbkdf2 with a random salt.
|
|
* Returns `salt:hash` in hex.
|
|
*/
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
const salt = crypto.randomBytes(SALT_BYTES).toString("hex");
|
|
|
|
return new Promise((resolve, reject) => {
|
|
crypto.pbkdf2(
|
|
password,
|
|
salt,
|
|
PBKDF2_ITERATIONS,
|
|
PBKDF2_KEYLEN,
|
|
PBKDF2_DIGEST,
|
|
(err, derivedKey) => {
|
|
if (err) return reject(err);
|
|
resolve(`${salt}:${derivedKey.toString("hex")}`);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Verify a password against a stored `salt:hash` string.
|
|
* Uses timing-safe comparison.
|
|
*/
|
|
export async function verifyPassword(
|
|
password: string,
|
|
stored: string,
|
|
): Promise<boolean> {
|
|
const [salt, originalHash] = stored.split(":");
|
|
if (!salt || !originalHash) return false;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
crypto.pbkdf2(
|
|
password,
|
|
salt,
|
|
PBKDF2_ITERATIONS,
|
|
PBKDF2_KEYLEN,
|
|
PBKDF2_DIGEST,
|
|
(err, derivedKey) => {
|
|
if (err) return reject(err);
|
|
|
|
const hashBuf = Buffer.from(originalHash, "hex");
|
|
if (hashBuf.length !== derivedKey.length) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
resolve(crypto.timingSafeEqual(hashBuf, derivedKey));
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate a random hex token.
|
|
*/
|
|
export function generateToken(bytes: number = 32): string {
|
|
return crypto.randomBytes(bytes).toString("hex");
|
|
}
|
|
|
|
/**
|
|
* Encrypt a session ID using AES-256-GCM.
|
|
* SESSION_SECRET must be a hex-encoded 32-byte key.
|
|
* Returns `iv:authTag:ciphertext` in hex.
|
|
*/
|
|
export function encryptSessionId(sessionId: string): string {
|
|
const key = Buffer.from(process.env.SESSION_SECRET!, "hex");
|
|
const iv = crypto.randomBytes(16);
|
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
|
|
const encrypted = Buffer.concat([
|
|
cipher.update(sessionId, "utf8"),
|
|
cipher.final(),
|
|
]);
|
|
const authTag = cipher.getAuthTag();
|
|
|
|
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
}
|
|
|
|
/**
|
|
* Decrypt a session ID encrypted with encryptSessionId.
|
|
* Returns null on any error.
|
|
*/
|
|
export function decryptSessionId(encrypted: string): string | null {
|
|
try {
|
|
const key = Buffer.from(process.env.SESSION_SECRET!, "hex");
|
|
const [ivHex, authTagHex, ciphertextHex] = encrypted.split(":");
|
|
if (!ivHex || !authTagHex || !ciphertextHex) return null;
|
|
|
|
const iv = Buffer.from(ivHex, "hex");
|
|
const authTag = Buffer.from(authTagHex, "hex");
|
|
const ciphertext = Buffer.from(ciphertextHex, "hex");
|
|
|
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
decipher.setAuthTag(authTag);
|
|
|
|
const decrypted = Buffer.concat([
|
|
decipher.update(ciphertext),
|
|
decipher.final(),
|
|
]);
|
|
|
|
return decrypted.toString("utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|