Files
kycnotme/web/src/lib/userSecretToken.ts
2025-06-14 18:56:58 +00:00

150 lines
4.9 KiB
TypeScript

import crypto from 'crypto'
import { z } from 'astro/zod'
import { escapeRegExp } from 'lodash-es'
import {
DIGIT_CHARACTERS,
LOWERCASE_CONSONANT_CHARACTERS,
LOWERCASE_VOWEL_CHARACTERS,
} from '../constants/characters'
import { getRandom, typedJoin } from './arrays'
import { DEPLOYMENT_MODE } from './client/envVariables'
import { transformCase } from './strings'
const DIGEST = 'sha512'
const USER_SECRET_TOKEN_LETTERS_SEGMENT_REGEX =
`(?:(?:[${typedJoin(LOWERCASE_VOWEL_CHARACTERS)}${transformCase(typedJoin(LOWERCASE_VOWEL_CHARACTERS), 'upper')}][${typedJoin(LOWERCASE_CONSONANT_CHARACTERS)}${transformCase(typedJoin(LOWERCASE_CONSONANT_CHARACTERS), 'upper')}]){2})` as const
const USER_SECRET_TOKEN_DIGITS_SEGMENT_REGEX = `(?:[${typedJoin(DIGIT_CHARACTERS)}]{4})` as const
const USER_SECRET_TOKEN_SEPARATOR_REGEX = '(?:(-| )+)'
export const includeDevUsers = DEPLOYMENT_MODE !== 'production'
const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
const specialUsersData = [
{
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
defaultToken: 'admin',
},
{
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
defaultToken: 'moderator',
},
{
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
defaultToken: 'verified',
},
{
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
defaultToken: 'normal',
},
{
envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
defaultToken: 'spam',
},
] as const satisfies {
envToken: string
defaultToken: string
}[]
const env =
// This file can also be called from seed.ts, where import.meta.env is not available
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(import.meta.env
? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]]))
: undefined) ?? process.env
return `(?:${typedJoin(
specialUsersData.map(({ envToken, defaultToken }) =>
typedJoin(
((env[envToken] as string | undefined) ?? defaultToken).match(/(.{4}|.{1,3}$)/g)?.map(
(segment) =>
`${segment
.split('')
.map((char) => `(?:${escapeRegExp(char.toUpperCase())}|${escapeRegExp(char.toLowerCase())})`)
.join('')}${USER_SECRET_TOKEN_SEPARATOR_REGEX}?`
) ?? []
)
),
'|'
)})` as const
})()
const USER_SECRET_TOKEN_FULL_REGEX_STRING =
`(?:(?:${USER_SECRET_TOKEN_LETTERS_SEGMENT_REGEX}${USER_SECRET_TOKEN_SEPARATOR_REGEX}?){4}${USER_SECRET_TOKEN_DIGITS_SEGMENT_REGEX})` as const
export const USER_SECRET_TOKEN_REGEX_STRING =
`^(?:${USER_SECRET_TOKEN_FULL_REGEX_STRING}${includeDevUsers ? `|${USER_SECRET_TOKEN_DEV_USERS_REGEX}` : ''})$` as const
export const USER_SECRET_TOKEN_REGEX = new RegExp(USER_SECRET_TOKEN_REGEX_STRING)
export const userSecretTokenZodSchema = z
.string()
.regex(USER_SECRET_TOKEN_REGEX)
.transform(parseUserSecretToken)
export function generateUserSecretToken(): string {
const token = [
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(DIGIT_CHARACTERS),
getRandom(DIGIT_CHARACTERS),
getRandom(DIGIT_CHARACTERS),
getRandom(DIGIT_CHARACTERS),
].join('')
return parseUserSecretToken(token)
}
export function hashUserSecretToken(token: string): string {
return crypto.createHash(DIGEST).update(token).digest('hex')
}
export function parseUserSecretToken(token: string): string {
if (!USER_SECRET_TOKEN_REGEX.test(token)) {
throw new Error(
`Invalid user secret token. Token "${token}" does not match regex ${USER_SECRET_TOKEN_REGEX_STRING}`
)
}
return token.toLocaleLowerCase().replace(new RegExp(USER_SECRET_TOKEN_SEPARATOR_REGEX, 'g'), '')
}
export function prettifyUserSecretToken(token: string): string {
const parsedToken = parseUserSecretToken(token)
const groups = parsedToken.toLocaleUpperCase().match(/.{4}/g)
if (!groups || groups.length !== 5) {
throw new Error('Error while prettifying user secret token')
}
return groups.join('-')
}
/**
* Verify a token against a stored hash using a constant-time comparison
*/
export function verifyUserSecretToken(token: string, hash: string): boolean {
const correctHash = hashUserSecretToken(token)
// Use crypto.timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(Buffer.from(correctHash, 'hex'), Buffer.from(hash, 'hex'))
}