102 lines
2.8 KiB
TypeScript
102 lines
2.8 KiB
TypeScript
const PASSWORD_HASH_ALGORITHM = "pbkdf2-sha256";
|
|
const PASSWORD_HASH_ITERATIONS = 100_000;
|
|
const PASSWORD_HASH_BYTES = 32;
|
|
const PASSWORD_SALT_BYTES = 16;
|
|
|
|
export const SESSION_TOKEN_BYTES = 32;
|
|
|
|
export const hashPassword = async (password: string) => {
|
|
const salt = crypto.getRandomValues(new Uint8Array(PASSWORD_SALT_BYTES));
|
|
const hash = await derivePasswordHash(password, salt, PASSWORD_HASH_ITERATIONS);
|
|
|
|
return [
|
|
PASSWORD_HASH_ALGORITHM,
|
|
PASSWORD_HASH_ITERATIONS,
|
|
base64UrlEncode(salt),
|
|
base64UrlEncode(hash),
|
|
].join("$");
|
|
};
|
|
|
|
export const verifyPassword = async (password: string, passwordHash: string) => {
|
|
const [algorithm, iterationsRaw, saltRaw, hashRaw] = passwordHash.split("$");
|
|
const iterations = Number(iterationsRaw);
|
|
|
|
if (
|
|
algorithm !== PASSWORD_HASH_ALGORITHM ||
|
|
!Number.isInteger(iterations) ||
|
|
iterations < PASSWORD_HASH_ITERATIONS ||
|
|
!saltRaw ||
|
|
!hashRaw
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const expected = base64UrlDecode(hashRaw);
|
|
const salt = base64UrlDecode(saltRaw);
|
|
const actual = await derivePasswordHash(password, salt, iterations);
|
|
|
|
return timingSafeEqual(actual, expected);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const derivePasswordHash = async (password: string, salt: Uint8Array, iterations: number) => {
|
|
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, [
|
|
"deriveBits",
|
|
]);
|
|
const saltBuffer = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
|
|
const bits = await crypto.subtle.deriveBits(
|
|
{
|
|
name: "PBKDF2",
|
|
hash: "SHA-256",
|
|
salt: saltBuffer,
|
|
iterations,
|
|
},
|
|
key,
|
|
PASSWORD_HASH_BYTES * 8
|
|
);
|
|
|
|
return new Uint8Array(bits);
|
|
};
|
|
|
|
export const randomToken = (bytes: number) => {
|
|
const token = crypto.getRandomValues(new Uint8Array(bytes));
|
|
return base64UrlEncode(token);
|
|
};
|
|
|
|
const base64UrlEncode = (bytes: Uint8Array) => {
|
|
let binary = "";
|
|
|
|
for (const byte of bytes) {
|
|
binary += String.fromCharCode(byte);
|
|
}
|
|
|
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
};
|
|
|
|
const base64UrlDecode = (value: string) => {
|
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
const binary = atob(padded);
|
|
const bytes = new Uint8Array(binary.length);
|
|
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
|
|
return bytes;
|
|
};
|
|
|
|
const timingSafeEqual = (left: Uint8Array, right: Uint8Array) => {
|
|
const length = Math.max(left.length, right.length);
|
|
let diff = left.length ^ right.length;
|
|
|
|
for (let index = 0; index < length; index += 1) {
|
|
diff |= (left[index % left.length] ?? 0) ^ (right[index % right.length] ?? 0);
|
|
}
|
|
|
|
return diff === 0;
|
|
};
|