90 lines
3.6 KiB
JavaScript
90 lines
3.6 KiB
JavaScript
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
|
|
import { promisify } from 'node:util'
|
|
import { AccessError } from './errors.mjs'
|
|
|
|
const scrypt = promisify(scryptCallback)
|
|
const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u
|
|
const CONTROL_CHARACTERS = /\p{Cc}/u
|
|
|
|
export const SCRYPT_POLICY = Object.freeze({ N: 32768, r: 8, p: 1, keyLength: 32, maxmem: 64 * 1024 * 1024 })
|
|
|
|
function codePoints(value) { return [...value].length }
|
|
|
|
export function normalizeUsername(input) {
|
|
if (typeof input !== 'string') throw new AccessError('USERNAME_INVALID', 'username is invalid')
|
|
const value = input.trim().normalize('NFC')
|
|
const bytes = Buffer.byteLength(value, 'utf8')
|
|
if (value.length === 0 || codePoints(value) > 64 || bytes > 256
|
|
|| CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) {
|
|
throw new AccessError('USERNAME_INVALID', 'username is invalid')
|
|
}
|
|
return value
|
|
}
|
|
|
|
export function normalizePassword(input) {
|
|
if (typeof input !== 'string') throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy')
|
|
const value = input.normalize('NFC')
|
|
const length = codePoints(value)
|
|
if (length < 8 || length > 1024 || Buffer.byteLength(value, 'utf8') > 1024
|
|
|| CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) {
|
|
throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy')
|
|
}
|
|
return value
|
|
}
|
|
|
|
function validParameters(value) {
|
|
return value !== null && typeof value === 'object'
|
|
&& Number.isInteger(value.N) && value.N >= 2
|
|
&& Number.isInteger(value.r) && value.r > 0
|
|
&& Number.isInteger(value.p) && value.p > 0
|
|
&& Number.isInteger(value.keyLength) && value.keyLength >= 16
|
|
&& Number.isInteger(value.maxmem) && value.maxmem >= 1024 * 1024
|
|
}
|
|
|
|
export async function createCredential(password, {
|
|
version = 1,
|
|
policy = SCRYPT_POLICY,
|
|
now = () => new Date(),
|
|
random = randomBytes,
|
|
derive = scrypt,
|
|
} = {}) {
|
|
const normalized = normalizePassword(password)
|
|
const salt = random(16)
|
|
const hash = await derive(normalized, salt, policy.keyLength, {
|
|
N: policy.N, r: policy.r, p: policy.p, maxmem: policy.maxmem,
|
|
})
|
|
return Object.freeze({
|
|
version,
|
|
algorithm: 'scrypt',
|
|
parameters: { ...policy },
|
|
salt: Buffer.from(salt).toString('base64url'),
|
|
hash: Buffer.from(hash).toString('base64url'),
|
|
changedAt: now().toISOString(),
|
|
})
|
|
}
|
|
|
|
export function validCredential(value) {
|
|
if (value === null || typeof value !== 'object' || value.algorithm !== 'scrypt'
|
|
|| !Number.isInteger(value.version) || value.version < 1 || !validParameters(value.parameters)
|
|
|| typeof value.salt !== 'string' || typeof value.hash !== 'string'
|
|
|| typeof value.changedAt !== 'string') return false
|
|
try {
|
|
return Buffer.from(value.salt, 'base64url').byteLength >= 16
|
|
&& Buffer.from(value.hash, 'base64url').byteLength === value.parameters.keyLength
|
|
&& Number.isFinite(Date.parse(value.changedAt))
|
|
} catch { return false }
|
|
}
|
|
|
|
export async function verifyCredential(password, credential, { derive = scrypt } = {}) {
|
|
if (!validCredential(credential)) return false
|
|
let normalized
|
|
try { normalized = normalizePassword(password) } catch { return false }
|
|
const expected = Buffer.from(credential.hash, 'base64url')
|
|
const salt = Buffer.from(credential.salt, 'base64url')
|
|
const parameters = credential.parameters
|
|
const actual = Buffer.from(await derive(normalized, salt, parameters.keyLength, {
|
|
N: parameters.N, r: parameters.r, p: parameters.p, maxmem: parameters.maxmem,
|
|
}))
|
|
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
|
|
}
|