Source: functions/validateModifier.js

/**
* simplePass - A JavaScript password generator.
* Copyright (C) 2023  Jordan Vezina(staticBanter)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/
'use strict';
import characterCodeConstraints from "../data/objects/characterCodeConstraints.js";
import config from "../simplePass.config.js";
import passwordPreConfigs from "../data/objects/passwordPreConfigs.js";
/**
 * @file
 * @module validateModifier
 */
/**
 * A basic validation function used to ensure the set [simplePass modifier object]{@link passwordModifier} attributes are usable.
 * This function will throw an error if the object is not usable, else the function will return ```void```.
 *
 * **WARNING!** simplePass does not preform comprehensive input sanitization/validation,
 * please ensure you are sanitizing and validating any inputs before they reach your server/application!
 *
 * @function validateModifier
 * @param {passwordModifier} modifier The [password modifier]{@link passwordModifier} object to validate.
 * @requires config
 * @throws {errors.invalidAttributeType} Will throw an error if a modifier attribute is not a valid type.
 * @throws {errors.outOfBoundsAttributeValue} Will throw an error if a modifier attribute is out of its allowed value bounds.
 * @throws {errors.toManyAttributes} Will throw an error if the modifier contains more attributes than the password can contain.
 * @throws {errors.missingRequiredAttribute} Will throw an error if a modifier attribute is missing another attribute that needs to be present.
 * @throws {errors.excludeCharactersContainedWhitespace} Will throw an error if the ```excludeCharacters``` attribute contains a whitespace.
 * @returns {void}
 */
export default function validateModifier(modifier) {
    // Ensure our modifier match our schema.
    Object.entries(passwordModifierSchema).forEach(([key, validator]) => {
        if (modifier[key]) {
            const result = validator(modifier[key]);
            if (!result) {
                throw new Error(`Invalid value for password modifier attribute ${key}`);
            }
        }
    });
    // Check if the password will be long enough to contain all the attributes.
    let modifierCount = Object.keys(modifier)
        .filter((item) => {
        return Object.keys(characterCodeConstraints).includes(item);
    })
        .length;
    // Check if the password should contain repeating characters
    if (modifier.repeatingCharacter) {
        /**
         * Determine if we are:
         *  Custom Repeating Characters
         *  Repeating random characters in the already generated password.
         *  Repeating a single character in the password.
         * * Note: It needs to be checked in this order.
         */
        if (modifier.customRepeatingCharacters) {
            // ^ Custom Repeating Characters
            // No string, bye bye...
            if (typeof (modifier.customRepeatingCharacters) !== 'string') {
                throw {
                    errorKey: 'invalidAttributeType',
                    replacements: [
                        'vM',
                        '15',
                        'customRepeatingCharacters',
                        'string'
                    ]
                };
            }
            // Trim any leading and trailing whitespace.
            modifier.customRepeatingCharacters = modifier.customRepeatingCharacters.trim();
            /**
             * IF the custom repeats contains spaces it must be a set of repeats.
             * ELSE IF it still contains a ":" (colon) it must be a single custom repeat.
             * ELSE it's just a string of requested repeating characters.
             * We must preform these checks in this order.
             */
            if (modifier.customRepeatingCharacters.includes(' ')) {
                // Initialize a holding array.
                const repeatingSets = [];
                // Split the string along the spaces.
                modifier.customRepeatingCharacters.split(' ').forEach((set) => {
                    /**
                     * IF any sets contains a literal character identifier "\" or ":".
                     * ELSE we shouldn't need to worry about splitting the string along the wrong character.
                     */
                    if (set.includes('\\')) {
                        /**
                         * Extract the character.
                         * * Note: only the second character in the string is being sliced.
                         * * We are assuming that the first character will be our identifier "\",
                         * * the second character will be our literal.
                         */
                        const literal = set.slice(1, 2);
                        // Remove the first instance of our literal character.
                        set = set.replace(literal, '');
                        const pieces = set.split(':');
                        if (pieces.length > 2) {
                            throw new Error('Custom Repeating Characters contain more than one ":" (colon) literal.');
                        }
                        /**
                         * Add the literal character and it's repeats to the holding array.
                         */
                        repeatingSets.push([literal, pieces[1]]);
                    }
                    else {
                        // ^ There shouldn't be a literal ":" (colon) in the string.
                        let pieces = set.split(':');
                        if (pieces.length > 2) {
                            throw new Error('Custom Repeating Characters contain more than one ":" (colon) literal.');
                        }
                        // Push the set to the array.
                        repeatingSets.push([pieces[0], pieces[1]]);
                    }
                });
                // Set the modifiers custom repeating characters to the holding array.
                modifier.customRepeatingCharacters = repeatingSets;
                modifier.customRepeatingCharacters.forEach((set) => {
                    /**
                     * If the repeats wasn't their or less than 1,
                     * Set repeats too 2.
                     * * Note
                     */
                    if (!set[1]
                        || parseInt(set[1]) <= 1) {
                        set[1] = '2';
                    }
                    /**
                     * Every repeating character replaces a potential password modification that can be added,
                     * therefore we need to increase the modifier count accordingly
                     * @ignore
                     */
                    modifierCount += parseInt(set[1]);
                });
            }
            else if (modifier.customRepeatingCharacters.includes(':')) {
                // ^ Single custom repeat.
                // Initialize a holding array.
                const repeatingSets = [];
                /**
                 * IF any sets contains a literal character identifier "\" or ":".
                 * ELSE we shouldn't need to worry about splitting the string along the wrong character.
                 */
                if (modifier.customRepeatingCharacters.includes('\\')) {
                    /**
                     * Extract the character.
                     * * Note: only the second character in the string is being sliced.
                     * * We are assuming that the first character will be our identifier "\",
                     * * the second character will be our literal.
                     */
                    const literal = modifier.customRepeatingCharacters.slice(1, 2);
                    // Remove the first instance of our literal character.
                    modifier.customRepeatingCharacters = modifier.customRepeatingCharacters.replace(literal, '');
                    const pieces = modifier.customRepeatingCharacters.split(':');
                    if (pieces.length > 2) {
                        throw new Error('Custom Repeating Characters contain more than one ":" (colon) literal.');
                    }
                    // Push the set to the array.
                    repeatingSets.push([literal, pieces[1]]);
                }
                else {
                    // ^ There shouldn't be a literal ":" (colon) in the string.
                    const pieces = modifier.customRepeatingCharacters.split(':');
                    if (pieces.length > 2) {
                        throw new Error('Custom Character Repeats contained an unescaped ":" (colon).');
                    }
                    // Push the set to the array.
                    repeatingSets.push([pieces[0], pieces[1]]);
                }
                // Set the modifiers custom repeating characters to the holding array.
                modifier.customRepeatingCharacters = repeatingSets;
                modifier.customRepeatingCharacters.forEach((set) => {
                    /**
                     * If the repeats wasn't their or less than 1,
                     * Set repeats too 2.
                     */
                    if (!set[1]
                        || parseInt(set[1]) <= 1) {
                        set[1] = '2';
                    }
                    /**
                     * Every repeating character replaces a potential password modification that can be added,
                     * therefore we need to increase the modifier count accordingly
                     * @ignore
                     */
                    modifierCount += parseInt(set[1]);
                });
            }
            else {
                // ^ String of repeating characters.
                /**
                 * Every repeating character replaces a potential password modification that can be added,
                 * therefore we need to increase the modifier count accordingly
                 * @ignore
                 */
                modifierCount += (modifier.customRepeatingCharacters.length * 2);
            }
        }
        else if (modifier.max_repeatingCharacter) {
            // ^ Repeating random characters in the already generated password.
            // If our Repeating Character Limit is not a string or a number throw an error.
            if (typeof (modifier.max_repeatingCharacter) !== 'number'
                && typeof (modifier.max_repeatingCharacter) !== 'string') {
                throw {
                    errorKey: 'invalidAttributeType',
                    replacements: ['vM', '13', 'max_repeatingCharacter', 'number or string']
                };
            }
            // Convert strings to ints.
            if (typeof (modifier.max_repeatingCharacter) === 'string') {
                modifier.max_repeatingCharacter = parseInt(modifier.max_repeatingCharacter);
            }
            /**
             * TODO: check the min and max values for the repeating character limit
             * this values should be dynamic based on the min and max password length values.
             */
            // Ensure the Repeating Character Limit is within a reasonable range.
            if (modifier.max_repeatingCharacter < 1
                || modifier.max_repeatingCharacter > 100) {
                throw {
                    errorKey: 'outOfBoundsAttributeValue',
                    replacements: ['vM', '14', 'max_repeatingCharacter']
                };
            }
            // Check if the requested password length can contain the amount of repeated characters.
            if (modifier.length < (modifier.max_repeatingCharacter * 2)) {
                throw {
                    errorKey: 'The password can not contain the requested amount of repeating characters.'
                };
            }
            /**
             * Every repeating character replaces a potential password modification that can be added,
             * therefore we need to increase the modifier count accordingly
             * @ignore
             */
            modifierCount += modifier.max_repeatingCharacter;
        }
        else {
            // ^ Repeating a single character in the password.
            modifierCount++;
        }
    }
    /**
     * Ensure the modifier contains at least one of these attributes.
     */
    if (!Object.keys(modifier)
        .some(function (array) {
        return Object.keys(characterCodeConstraints).includes(array);
    })) {
        throw {
            errorKey: 'missingRequiredAttributes',
            replacements: ['vM', '7', Object.keys(characterCodeConstraints).toString()]
        };
    }
    if (modifierCount > modifier.length) {
        throw {
            errorKey: 'toManyAttributes',
            replacements: ['vM', '3', `${modifier.length}`, `${modifierCount}`]
        };
    }
    return true;
}
const passwordModifierSchema = {
    // Modifiers
    length: (v) => {
        v = parseInt(v);
        return (typeof (v) === 'number'
            && (v % 1) === 0
            && v >= config.passwordConstraints.min_length
            && v <= config.passwordConstraints.max_length);
    },
    excludeCharacters: (v) => {
        return (typeof (v) === 'string'
            && !(new RegExp(/[\s]/g).test(v))
            && v.length >= config.passwordConstraints.min_excludeCharactersLength
            && v.length <= config.passwordConstraints.max_excludeCharactersLength);
    },
    uniqueCharacters: (v) => { return (v ? true : false); },
    repeatingCharacter: (v) => { return (v ? true : false); },
    max_repeatingCharacter: (v) => {
        v = parseInt(v);
        return (typeof (v) === 'number'
            && (v % 1) === 0
            && v >= config.passwordConstraints.min_repeatingCharactersLength
            && v <= config.passwordConstraints.max_repeatingCharactersLength);
    },
    customRepeatingCharacters: (v) => {
        if (Array.isArray(v)) {
            if (!v.length) {
                return false;
            }
            for (let i = 0; i < v.length; i++) {
                if (!Array.isArray(v[i])
                    || v[i].length > 1
                    || typeof (v[i][0]) === 'string') {
                    return false;
                }
            }
        }
        else if (typeof (v) === 'string') {
            return (v.length
                && v.length >= config.passwordConstraints.min_repeatingCharactersLength
                && v.length <= config.passwordConstraints.max_repeatingCharactersLength);
        }
        return false;
    },
    preConfig: (v) => {
        return (typeof (v) === 'string'
            && Object.keys(passwordPreConfigs).includes(v));
    },
    // Whitespace
    whitespaceBeginning: (v) => { return (v ? true : false); },
    required_whitespaceBeginning: (v) => { return (v ? true : false); },
    whitespaceEnd: (v) => { return (v ? true : false); },
    required_whitespaceEnd: (v) => { return (v ? true : false); },
    whitespaceBetween: (v) => { return (v ? true : false); },
    max_whitespaceBetween: (v) => {
        v = parseInt(v);
        return (typeof (v) === 'number'
            && (v % 1) === 0
            && v >= config.passwordConstraints.min_whitespaceBetween()
            && v <= config.passwordConstraints.max_whitespaceBetween());
    },
    // Character Sets
    lowercase: (v) => { return (v ? true : false); },
    uppercase: (v) => { return (v ? true : false); },
    numbers: (v) => { return (v ? true : false); },
    punctuation: (v) => { return (v ? true : false); },
    lowercase_supplement: (v) => { return (v ? true : false); },
    uppercase_supplement: (v) => { return (v ? true : false); },
    symbols_supplement: (v) => { return (v ? true : false); },
    lowercase_extended_a: (v) => { return (v ? true : false); },
    uppercase_extended_a: (v) => { return (v ? true : false); },
    ligature_extended_a: (v) => { return (v ? true : false); },
    lowercase_extended_b: (v) => { return (v ? true : false); },
    uppercase_extended_b: (v) => { return (v ? true : false); },
    mixedcase_extended_b: (v) => { return (v ? true : false); },
    insensitivecase_extended_b: (v) => { return (v ? true : false); },
    lowercase_ipa_extension: (v) => { return (v ? true : false); },
    uppercase_ipa_extension: (v) => { return (v ? true : false); },
    character_modifier_letters: (v) => { return (v ? true : false); },
    symbol_modifier_letters: (v) => { return (v ? true : false); },
    lowercase_greek_coptic: (v) => { return (v ? true : false); },
    uppercase_greek_coptic: (v) => { return (v ? true : false); },
    insensitivecase_greek_coptic: (v) => { return (v ? true : false); },
    symbol_greek_coptic: (v) => { return (v ? true : false); },
    lowercase_cyrillic: (v) => { return (v ? true : false); },
    uppercase_cyrillic: (v) => { return (v ? true : false); },
    symbols_cyrillic: (v) => { return (v ? true : false); },
    lowercase_cyrillic_supplement: (v) => { return (v ? true : false); },
    uppercase_cyrillic_supplement: (v) => { return (v ? true : false); },
    misc_cyrillic_supplement: (v) => { return (v ? true : false); },
    general_punctuation: (v) => { return (v ? true : false); },
    currency_symbols: (v) => { return (v ? true : false); },
    misc_technical: (v) => { return (v ? true : false); },
    box_drawings: (v) => { return (v ? true : false); },
    block_elements: (v) => { return (v ? true : false); },
    misc_symbols: (v) => { return (v ? true : false); },
    dingbats: (v) => { return (v ? true : false); },
};