| const { webcrypto } = require('node:crypto'); |
| const { hashBackupCode, decryptV3, decryptV2 } = require('@librechat/api'); |
| const { updateUser } = require('~/models'); |
|
|
| |
| const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; |
|
|
| |
| |
| |
| |
| |
| const encodeBase32 = (buffer) => { |
| 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; |
| }; |
|
|
| |
| |
| |
| |
| |
| const decodeBase32 = (base32Str) => { |
| const cleaned = base32Str.replace(/=+$/, '').toUpperCase(); |
| let bits = 0; |
| let value = 0; |
| const output = []; |
| for (const char of cleaned) { |
| const idx = BASE32_ALPHABET.indexOf(char); |
| if (idx === -1) { |
| continue; |
| } |
| value = (value << 5) | idx; |
| bits += 5; |
| if (bits >= 8) { |
| output.push((value >>> (bits - 8)) & 0xff); |
| bits -= 8; |
| } |
| } |
| return Buffer.from(output); |
| }; |
|
|
| |
| |
| |
| |
| const generateTOTPSecret = () => { |
| const randomArray = new Uint8Array(10); |
| webcrypto.getRandomValues(randomArray); |
| return encodeBase32(Buffer.from(randomArray)); |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const generateTOTP = async (secret, forTime = Date.now()) => { |
| const timeStep = 30; |
| const counter = Math.floor(forTime / 1000 / timeStep); |
| const counterBuffer = new ArrayBuffer(8); |
| const counterView = new DataView(counterBuffer); |
| counterView.setUint32(4, counter, false); |
|
|
| const keyBuffer = decodeBase32(secret); |
| const keyArrayBuffer = keyBuffer.buffer.slice( |
| keyBuffer.byteOffset, |
| keyBuffer.byteOffset + keyBuffer.byteLength, |
| ); |
|
|
| const cryptoKey = await webcrypto.subtle.importKey( |
| 'raw', |
| keyArrayBuffer, |
| { name: 'HMAC', hash: 'SHA-1' }, |
| false, |
| ['sign'], |
| ); |
| const signatureBuffer = await webcrypto.subtle.sign('HMAC', cryptoKey, counterBuffer); |
| const hmac = new Uint8Array(signatureBuffer); |
|
|
| |
| const offset = hmac[hmac.length - 1] & 0xf; |
| const slice = hmac.slice(offset, offset + 4); |
| const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); |
| const binaryCode = view.getUint32(0, false) & 0x7fffffff; |
| const code = (binaryCode % 1000000).toString().padStart(6, '0'); |
| return code; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| const verifyTOTP = async (secret, token) => { |
| const timeStepMS = 30 * 1000; |
| const currentTime = Date.now(); |
| for (let offset = -1; offset <= 1; offset++) { |
| const expected = await generateTOTP(secret, currentTime + offset * timeStepMS); |
| if (expected === token) { |
| return true; |
| } |
| } |
| return false; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| const generateBackupCodes = async (count = 10) => { |
| const plainCodes = []; |
| const codeObjects = []; |
| const encoder = new TextEncoder(); |
|
|
| for (let i = 0; i < count; i++) { |
| const randomArray = new Uint8Array(4); |
| webcrypto.getRandomValues(randomArray); |
| const code = Array.from(randomArray) |
| .map((b) => b.toString(16).padStart(2, '0')) |
| .join(''); |
| plainCodes.push(code); |
|
|
| const codeBuffer = encoder.encode(code); |
| const hashBuffer = await webcrypto.subtle.digest('SHA-256', codeBuffer); |
| const hashArray = Array.from(new Uint8Array(hashBuffer)); |
| const codeHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); |
| codeObjects.push({ codeHash, used: false, usedAt: null }); |
| } |
| return { plainCodes, codeObjects }; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const verifyBackupCode = async ({ user, backupCode }) => { |
| if (!backupCode || !user || !Array.isArray(user.backupCodes)) { |
| return false; |
| } |
|
|
| const hashedInput = await hashBackupCode(backupCode.trim()); |
| const matchingCode = user.backupCodes.find( |
| (codeObj) => codeObj.codeHash === hashedInput && !codeObj.used, |
| ); |
|
|
| if (matchingCode) { |
| const updatedBackupCodes = user.backupCodes.map((codeObj) => |
| codeObj.codeHash === hashedInput && !codeObj.used |
| ? { ...codeObj, used: true, usedAt: new Date() } |
| : codeObj, |
| ); |
| |
| await updateUser(user._id, { backupCodes: updatedBackupCodes }); |
| return true; |
| } |
| return false; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const getTOTPSecret = async (storedSecret) => { |
| if (!storedSecret) { |
| return null; |
| } |
| if (storedSecret.startsWith('v3:')) { |
| return decryptV3(storedSecret); |
| } |
| if (storedSecret.includes(':')) { |
| return await decryptV2(storedSecret); |
| } |
| if (storedSecret.length === 16) { |
| return storedSecret; |
| } |
| return storedSecret; |
| }; |
|
|
| |
| |
| |
| |
| |
| const generate2FATempToken = (userId) => { |
| const { sign } = require('jsonwebtoken'); |
| return sign({ userId, twoFAPending: true }, process.env.JWT_SECRET, { expiresIn: '5m' }); |
| }; |
|
|
| module.exports = { |
| generateTOTPSecret, |
| generateTOTP, |
| verifyTOTP, |
| generateBackupCodes, |
| verifyBackupCode, |
| getTOTPSecret, |
| generate2FATempToken, |
| }; |
|
|