add 2MFA
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const DEFAULT_PERIOD_SECONDS = 30;
|
||||
const DEFAULT_DIGITS = 6;
|
||||
const DEFAULT_WINDOW = 1;
|
||||
|
||||
export function generateMfaSecret(length = 20): string {
|
||||
const bytes = new Uint8Array(Math.max(10, Math.min(64, length)));
|
||||
crypto.getRandomValues(bytes);
|
||||
return base32Encode(Buffer.from(bytes));
|
||||
}
|
||||
|
||||
export function buildTotpUri(params: {
|
||||
issuer: string;
|
||||
accountName: string;
|
||||
secret: string;
|
||||
}): string {
|
||||
const issuer = params.issuer || "Overlord";
|
||||
const label = `${issuer}:${params.accountName}`;
|
||||
const search = new URLSearchParams({
|
||||
secret: params.secret,
|
||||
issuer,
|
||||
algorithm: "SHA1",
|
||||
digits: String(DEFAULT_DIGITS),
|
||||
period: String(DEFAULT_PERIOD_SECONDS),
|
||||
});
|
||||
return `otpauth://totp/${encodeURIComponent(label)}?${search.toString()}`;
|
||||
}
|
||||
|
||||
export function generateTotpCode(
|
||||
secret: string,
|
||||
timestamp = Date.now(),
|
||||
digits = DEFAULT_DIGITS,
|
||||
periodSeconds = DEFAULT_PERIOD_SECONDS,
|
||||
): string {
|
||||
const key = base32Decode(secret);
|
||||
const counter = Math.floor(timestamp / 1000 / periodSeconds);
|
||||
const counterBuffer = Buffer.alloc(8);
|
||||
counterBuffer.writeBigUInt64BE(BigInt(counter), 0);
|
||||
|
||||
const digest = createHmac("sha1", key).update(counterBuffer).digest();
|
||||
const offset = digest[digest.length - 1] & 0x0f;
|
||||
const binary =
|
||||
((digest[offset] & 0x7f) << 24) |
|
||||
((digest[offset + 1] & 0xff) << 16) |
|
||||
((digest[offset + 2] & 0xff) << 8) |
|
||||
(digest[offset + 3] & 0xff);
|
||||
|
||||
return String(binary % 10 ** digits).padStart(digits, "0");
|
||||
}
|
||||
|
||||
export function verifyTotpCode(
|
||||
secret: string,
|
||||
code: string,
|
||||
timestamp = Date.now(),
|
||||
window = DEFAULT_WINDOW,
|
||||
): boolean {
|
||||
const normalized = String(code || "").replace(/\s+/g, "");
|
||||
if (!/^\d{6}$/.test(normalized)) return false;
|
||||
|
||||
for (let offset = -window; offset <= window; offset++) {
|
||||
const candidate = generateTotpCode(
|
||||
secret,
|
||||
timestamp + offset * DEFAULT_PERIOD_SECONDS * 1000,
|
||||
);
|
||||
if (safeEqual(candidate, normalized)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function base32Encode(buffer: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = "";
|
||||
|
||||
for (const byte of buffer) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (bits > 0) {
|
||||
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function base32Decode(input: string): Buffer {
|
||||
const clean = input.toUpperCase().replace(/=+$/g, "").replace(/\s+/g, "");
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (const char of clean) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) {
|
||||
throw new Error("Invalid MFA secret");
|
||||
}
|
||||
value = (value << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bytes.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
Reference in New Issue
Block a user