| | 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, |
| | }; |
| |
|