mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 13:28:12 -04:00
Implement identity and password generator in mobile app (#771)
This commit is contained in:
@@ -10,11 +10,16 @@ import { useWebApi } from '@/context/WebApiContext';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import { Gender } from "@/utils/generators/Identity/types/Gender";
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
import { FaviconExtractModel } from '@/utils/types/webapi/FaviconExtractModel';
|
||||
import { AliasVaultToast } from '@/components/Toast';
|
||||
import { useVaultMutate } from '@/hooks/useVaultMutate';
|
||||
import { Gender } from '@/utils/shared/identity-generator';
|
||||
import { IdentityGeneratorEn } from '@/utils/shared/identity-generator';
|
||||
import { IdentityGeneratorNl } from '@/utils/shared/identity-generator';
|
||||
import { PasswordGenerator } from '@/utils/shared/password-generator';
|
||||
import { BaseIdentityGenerator } from '@/utils/shared/identity-generator';
|
||||
|
||||
type CredentialMode = 'random' | 'manual';
|
||||
|
||||
export default function AddEditCredentialScreen() {
|
||||
@@ -163,30 +168,59 @@ export default function AddEditCredentialScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const generateRandomValues = () : Credential => {
|
||||
// Placeholder for random generation - will be replaced with actual generators
|
||||
const randomString = (length: number) => Math.random().toString(36).substring(2, length + 2);
|
||||
const generateRandomValues = async () : Promise<Credential> => {
|
||||
try {
|
||||
console.log('Generating random values');
|
||||
// Get default identity language and password settings from database
|
||||
const identityLanguage = await dbContext.sqliteClient!.getDefaultIdentityLanguage();
|
||||
const passwordSettings = await dbContext.sqliteClient!.getPasswordSettings();
|
||||
const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain();
|
||||
|
||||
// Get the current credential
|
||||
const updatedCredential = credential;
|
||||
// Initialize identity generator based on language
|
||||
let identityGenerator: BaseIdentityGenerator;
|
||||
switch (identityLanguage) {
|
||||
case 'nl':
|
||||
identityGenerator = new IdentityGeneratorNl();
|
||||
break;
|
||||
case 'en':
|
||||
default:
|
||||
identityGenerator = new IdentityGeneratorEn();
|
||||
break;
|
||||
}
|
||||
|
||||
// Assign random values to all fields
|
||||
updatedCredential.Username = randomString(8);
|
||||
updatedCredential.Password = randomString(12);
|
||||
// Generate random identity
|
||||
const identity = await identityGenerator.generateRandomIdentity();
|
||||
|
||||
updatedCredential.Alias = {
|
||||
...(updatedCredential.Alias ?? {}),
|
||||
Email: `${randomString(8)}@example.com`,
|
||||
FirstName: randomString(6),
|
||||
LastName: randomString(6),
|
||||
NickName: randomString(6),
|
||||
Gender: Math.random() > 0.5 ? Gender.Male : Gender.Female,
|
||||
BirthDate: '0001-01-01 00:00:00',
|
||||
};
|
||||
// Initialize password generator with settings
|
||||
const passwordGenerator = new PasswordGenerator(passwordSettings);
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
|
||||
setCredential(updatedCredential);
|
||||
// Create email with domain if available
|
||||
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
|
||||
|
||||
return updatedCredential as Credential;
|
||||
// Assign generated values
|
||||
const updatedCredential: Partial<Credential> = {
|
||||
...credential,
|
||||
Username: identity.nickName,
|
||||
Password: password,
|
||||
Alias: {
|
||||
...(credential.Alias ?? {}),
|
||||
Email: email,
|
||||
FirstName: identity.firstName,
|
||||
LastName: identity.lastName,
|
||||
NickName: identity.nickName,
|
||||
Gender: identity.gender,
|
||||
BirthDate: identity.birthDate.toISOString(),
|
||||
}
|
||||
};
|
||||
|
||||
setCredential(updatedCredential);
|
||||
|
||||
return updatedCredential as Credential;
|
||||
} catch (error) {
|
||||
console.error('Error generating random values:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -197,7 +231,7 @@ export default function AddEditCredentialScreen() {
|
||||
// If mode is random, generate random values for all fields before saving.
|
||||
if (mode === 'random') {
|
||||
console.log('Generating random values');
|
||||
credentialToSave = generateRandomValues();
|
||||
credentialToSave = await generateRandomValues();
|
||||
}
|
||||
|
||||
await executeVaultMutation(async () => {
|
||||
|
||||
@@ -162,7 +162,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
clearDatabase,
|
||||
getVaultMetadata,
|
||||
testDatabaseConnection,
|
||||
unlockVault
|
||||
unlockVault,
|
||||
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, testDatabaseConnection, unlockVault]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,6 +49,54 @@ class SqliteClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default email domain from the vault metadata.
|
||||
* Returns the first valid private domain if available, otherwise the first valid public domain.
|
||||
* Returns null if no valid domains are found.
|
||||
*/
|
||||
public async getDefaultEmailDomain(): Promise<string | null> {
|
||||
try {
|
||||
const metadata = await this.getVaultMetadata();
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { privateEmailDomains, publicEmailDomains } = metadata;
|
||||
|
||||
// Check if a domain is valid (not empty, not 'DISABLED.TLD', and exists in either private or public domains)
|
||||
const isValidDomain = (domain: string): boolean => {
|
||||
return Boolean(domain &&
|
||||
domain !== 'DISABLED.TLD' &&
|
||||
(privateEmailDomains?.includes(domain) || publicEmailDomains?.includes(domain)));
|
||||
};
|
||||
|
||||
// Get the default email domain from vault settings
|
||||
const defaultEmailDomain = await this.getSetting('DefaultEmailDomain');
|
||||
|
||||
// First check if the default domain that is configured in the vault is still valid
|
||||
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
|
||||
return defaultEmailDomain;
|
||||
}
|
||||
|
||||
// If default domain is not valid, fall back to first available private domain
|
||||
const firstPrivate = privateEmailDomains?.find(isValidDomain);
|
||||
if (firstPrivate) {
|
||||
return firstPrivate;
|
||||
}
|
||||
|
||||
// Return first valid public domain if no private domains are available
|
||||
const firstPublic = publicEmailDomains?.find(isValidDomain);
|
||||
if (firstPublic) {
|
||||
return firstPublic;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error getting default email domain:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the encryption key in the native keychain
|
||||
*/
|
||||
@@ -308,13 +356,6 @@ class SqliteClient {
|
||||
return results.length > 0 ? results[0].Value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default email domain from the database.
|
||||
*/
|
||||
public async getDefaultEmailDomain(): Promise<string> {
|
||||
return this.getSetting('DefaultEmailDomain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity language from the database.
|
||||
*/
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { Identity } from "./types/Identity";
|
||||
|
||||
/**
|
||||
* Generate a username or email prefix.
|
||||
*/
|
||||
export class UsernameEmailGenerator {
|
||||
private static readonly MIN_LENGTH = 6;
|
||||
private static readonly MAX_LENGTH = 20;
|
||||
private readonly symbols: string[] = ['.', '-'];
|
||||
|
||||
/**
|
||||
* Generate a username based on an identity.
|
||||
*/
|
||||
public generateUsername(identity: Identity): string {
|
||||
// Generate username based on email prefix but strip all non-alphanumeric characters
|
||||
let username = this.generateEmailPrefix(identity);
|
||||
username = username.replace(/[^a-zA-Z0-9]/g, '');
|
||||
|
||||
// Adjust length
|
||||
if (username.length < UsernameEmailGenerator.MIN_LENGTH) {
|
||||
username += this.generateRandomString(UsernameEmailGenerator.MIN_LENGTH - username.length);
|
||||
} else if (username.length > UsernameEmailGenerator.MAX_LENGTH) {
|
||||
username = username.substring(0, UsernameEmailGenerator.MAX_LENGTH);
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an email prefix based on an identity.
|
||||
*/
|
||||
public generateEmailPrefix(identity: Identity): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
switch (this.getSecureRandom(4)) {
|
||||
case 0:
|
||||
// First initial + last name
|
||||
parts.push(identity.firstName.substring(0, 1).toLowerCase() + identity.lastName.toLowerCase());
|
||||
break;
|
||||
case 1:
|
||||
// Full name
|
||||
parts.push((identity.firstName + identity.lastName).toLowerCase());
|
||||
break;
|
||||
case 2:
|
||||
// First name + last initial
|
||||
parts.push(identity.firstName.toLowerCase() + identity.lastName.substring(0, 1).toLowerCase());
|
||||
break;
|
||||
case 3:
|
||||
// First 3 chars of first name + last name
|
||||
parts.push(identity.firstName.substring(0, Math.min(3, identity.firstName.length)).toLowerCase() + identity.lastName.toLowerCase());
|
||||
break;
|
||||
}
|
||||
|
||||
// Add birth year variations
|
||||
if (this.getSecureRandom(3) !== 0) {
|
||||
switch (this.getSecureRandom(2)) {
|
||||
case 0:
|
||||
parts.push(identity.birthDate.getFullYear().toString().substring(2));
|
||||
break;
|
||||
case 1:
|
||||
parts.push(identity.birthDate.getFullYear().toString());
|
||||
break;
|
||||
}
|
||||
} else if (this.getSecureRandom(2) === 0) {
|
||||
// Add random numbers for more uniqueness
|
||||
parts.push((this.getSecureRandom(990) + 10).toString());
|
||||
}
|
||||
|
||||
// Join parts with random symbols, possibly multiple
|
||||
let emailPrefix = parts.join(this.getRandomSymbol());
|
||||
|
||||
// Add extra random symbol at random position
|
||||
if (this.getSecureRandom(2) === 0) {
|
||||
const position = this.getSecureRandom(emailPrefix.length);
|
||||
emailPrefix = emailPrefix.slice(0, position) + this.getRandomSymbol() + emailPrefix.slice(position);
|
||||
}
|
||||
|
||||
emailPrefix = this.sanitizeEmailPrefix(emailPrefix);
|
||||
|
||||
// Adjust length
|
||||
if (emailPrefix.length < UsernameEmailGenerator.MIN_LENGTH) {
|
||||
emailPrefix += this.generateRandomString(UsernameEmailGenerator.MIN_LENGTH - emailPrefix.length);
|
||||
} else if (emailPrefix.length > UsernameEmailGenerator.MAX_LENGTH) {
|
||||
emailPrefix = emailPrefix.substring(0, UsernameEmailGenerator.MAX_LENGTH);
|
||||
}
|
||||
|
||||
return emailPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an email prefix.
|
||||
*/
|
||||
private sanitizeEmailPrefix(input: string): string {
|
||||
// Remove any character that's not a letter, number, dot, underscore, or hyphen including special characters
|
||||
let sanitized = input.replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
|
||||
// Remove consecutive dots, underscores, or hyphens
|
||||
sanitized = sanitized.replace(/[-_.]{2,}/g, (match) => match[0]);
|
||||
|
||||
// Remove leading and trailing dots, underscores, or hyphens
|
||||
sanitized = sanitized.replace(/^[-._]+/, ''); // Remove from start
|
||||
sanitized = sanitized.replace(/[-._]*$/, ''); // Remove from end
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random symbol.
|
||||
*/
|
||||
private getRandomSymbol(): string {
|
||||
return this.getSecureRandom(3) === 0 ? this.symbols[this.getSecureRandom(this.symbols.length)] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string.
|
||||
*/
|
||||
private generateRandomString(length: number): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
return Array.from({ length }, () => chars.charAt(this.getSecureRandom(chars.length))).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random integer between 0 (inclusive) and max (exclusive)
|
||||
*/
|
||||
private getSecureRandom(max: number): number {
|
||||
const array = new Uint32Array(1);
|
||||
crypto.getRandomValues(array);
|
||||
return array[0] % max;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { IdentityGeneratorEn } from '../implementations/IdentityGeneratorEn';
|
||||
import { IdentityGeneratorNl } from '../implementations/IdentityGeneratorNl';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { IIdentityGenerator } from '../interfaces/IIdentityGenerator';
|
||||
import { Gender } from '../types/Gender';
|
||||
|
||||
/**
|
||||
* Test the identity generator.
|
||||
*/
|
||||
const testIdentityGenerator = (
|
||||
language: string,
|
||||
generator: IIdentityGenerator
|
||||
) : void => {
|
||||
describe(`IdentityGenerator${language}`, () => {
|
||||
describe('generateRandomIdentity', () => {
|
||||
it('should generate a random gender identity when no gender is specified', async () => {
|
||||
const identity = await generator.generateRandomIdentity();
|
||||
|
||||
expect(identity).toBeDefined();
|
||||
expect(identity.firstName).toBeTruthy();
|
||||
expect(identity.lastName).toBeTruthy();
|
||||
expect([Gender.Male, Gender.Female]).toContain(identity.gender);
|
||||
});
|
||||
|
||||
it('should generate unique identities on subsequent calls', async () => {
|
||||
const identity1 = await generator.generateRandomIdentity();
|
||||
const identity2 = await generator.generateRandomIdentity();
|
||||
|
||||
expect(identity1).not.toEqual(identity2);
|
||||
});
|
||||
|
||||
it('should generate an identity with all non-empty fields', async () => {
|
||||
const identity = await generator.generateRandomIdentity();
|
||||
|
||||
Object.entries(identity).forEach(([, value]) => {
|
||||
expect(value).toBeTruthy();
|
||||
expect(value).not.toBe('');
|
||||
expect(value).not.toBeNull();
|
||||
expect(value).not.toBeUndefined();
|
||||
});
|
||||
|
||||
// Check if the first and last names are longer than 1 character.
|
||||
expect(identity.firstName.length).toBeGreaterThan(1);
|
||||
expect(identity.lastName.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Run tests for each language implementation
|
||||
describe('Identity Generators', () => {
|
||||
testIdentityGenerator('En', new IdentityGeneratorEn());
|
||||
testIdentityGenerator('Nl', new IdentityGeneratorNl());
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { BaseIdentityGenerator } from "./base/BaseIdentityGenerator";
|
||||
|
||||
/**
|
||||
* Identity generator for English language using English word dictionaries.
|
||||
*/
|
||||
export class IdentityGeneratorEn extends BaseIdentityGenerator {
|
||||
/**
|
||||
* Get the male first names.
|
||||
*/
|
||||
protected getFirstNamesMaleJson(): string[] {
|
||||
/*
|
||||
* This is a placeholder for the dictionary-loader to replace with the actual data.
|
||||
* See vite-plugins/dictionary-loader.ts for more information.
|
||||
*/
|
||||
return '__FIRSTNAMES_MALE_EN__';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the female first names.
|
||||
*/
|
||||
protected getFirstNamesFemaleJson(): string[] {
|
||||
/*
|
||||
* This is a placeholder for the dictionary-loader to replace with the actual data.
|
||||
* See vite-plugins/dictionary-loader.ts for more information.
|
||||
*/
|
||||
return '__FIRSTNAMES_FEMALE_EN__';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last names.
|
||||
*/
|
||||
protected getLastNamesJson(): string[] {
|
||||
/*
|
||||
* This is a placeholder for the dictionary-loader to replace with the actual data.
|
||||
* See vite-plugins/dictionary-loader.ts for more information.
|
||||
*/
|
||||
return '__LASTNAMES_EN__';
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { BaseIdentityGenerator } from "./base/BaseIdentityGenerator";
|
||||
|
||||
/**
|
||||
* Identity generator for Dutch language using Dutch word dictionaries.
|
||||
*/
|
||||
export class IdentityGeneratorNl extends BaseIdentityGenerator {
|
||||
/**
|
||||
* Get the male first names.
|
||||
*/
|
||||
protected getFirstNamesMaleJson(): string[] {
|
||||
/*
|
||||
* This is a placeholder for the dictionary-loader to replace with the actual data.
|
||||
* See vite-plugins/dictionary-loader.ts for more information.
|
||||
*/
|
||||
return '__FIRSTNAMES_MALE_NL__';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the female first names.
|
||||
*/
|
||||
protected getFirstNamesFemaleJson(): string[] {
|
||||
/*
|
||||
* This is a placeholder for the dictionary-loader to replace with the actual data.
|
||||
* See vite-plugins/dictionary-loader.ts for more information.
|
||||
*/
|
||||
return '__FIRSTNAMES_FEMALE_NL__';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last names.
|
||||
*/
|
||||
protected getLastNamesJson(): string[] {
|
||||
/*
|
||||
* This is a placeholder for the dictionary-loader to replace with the actual data.
|
||||
* See vite-plugins/dictionary-loader.ts for more information.
|
||||
*/
|
||||
return '__LASTNAMES_NL__';
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { UsernameEmailGenerator } from '../../UsernameEmailGenerator';
|
||||
import { Gender } from '../../types/Gender';
|
||||
import { IIdentityGenerator } from '../../interfaces/IIdentityGenerator';
|
||||
import { Identity } from '../../types/Identity';
|
||||
|
||||
/**
|
||||
* Base identity generator.
|
||||
*/
|
||||
export abstract class BaseIdentityGenerator implements IIdentityGenerator {
|
||||
protected firstNamesMale: string[] = [];
|
||||
protected firstNamesFemale: string[] = [];
|
||||
protected lastNames: string[] = [];
|
||||
private readonly random = Math.random;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public constructor() {
|
||||
// Each implementing class should provide these as static JSON strings
|
||||
this.firstNamesMale = this.getFirstNamesMaleJson();
|
||||
this.firstNamesFemale = this.getFirstNamesFemaleJson();
|
||||
this.lastNames = this.getLastNamesJson();
|
||||
}
|
||||
|
||||
protected abstract getFirstNamesMaleJson(): string[];
|
||||
protected abstract getFirstNamesFemaleJson(): string[];
|
||||
protected abstract getLastNamesJson(): string[];
|
||||
|
||||
/**
|
||||
* Generate a random date of birth.
|
||||
*/
|
||||
protected generateRandomDateOfBirth(): Date {
|
||||
const today = new Date();
|
||||
const minAge = 21;
|
||||
const maxAge = 65;
|
||||
|
||||
const minDate = new Date(today.getFullYear() - maxAge, today.getMonth(), today.getDate());
|
||||
const maxDate = new Date(today.getFullYear() - minAge, today.getMonth(), today.getDate());
|
||||
|
||||
const timestamp = minDate.getTime() + (this.random() * (maxDate.getTime() - minDate.getTime()));
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random identity.
|
||||
*/
|
||||
public async generateRandomIdentity(): Promise<Identity> {
|
||||
const identity: Identity = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
gender: Gender.Male,
|
||||
birthDate: new Date(),
|
||||
emailPrefix: '',
|
||||
nickName: ''
|
||||
};
|
||||
|
||||
// Determine gender
|
||||
if (this.random() < 0.5) {
|
||||
identity.firstName = this.firstNamesMale[Math.floor(this.random() * this.firstNamesMale.length)];
|
||||
identity.gender = Gender.Male;
|
||||
} else {
|
||||
identity.firstName = this.firstNamesFemale[Math.floor(this.random() * this.firstNamesFemale.length)];
|
||||
identity.gender = Gender.Female;
|
||||
}
|
||||
|
||||
identity.lastName = this.lastNames[Math.floor(this.random() * this.lastNames.length)];
|
||||
identity.birthDate = this.generateRandomDateOfBirth();
|
||||
|
||||
const generator = new UsernameEmailGenerator();
|
||||
identity.emailPrefix = generator.generateEmailPrefix(identity);
|
||||
identity.nickName = generator.generateUsername(identity);
|
||||
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Identity } from "../types/Identity";
|
||||
|
||||
export interface IIdentityGenerator {
|
||||
generateRandomIdentity(): Promise<Identity>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum Gender {
|
||||
Male = 'Male',
|
||||
Female = 'Female',
|
||||
Other = 'Other'
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Gender } from './Gender';
|
||||
|
||||
/**
|
||||
* Identity.
|
||||
*/
|
||||
export type Identity = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
gender: Gender;
|
||||
birthDate: Date;
|
||||
emailPrefix: string;
|
||||
nickName: string;
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { PasswordSettings } from '../../types/PasswordSettings';
|
||||
|
||||
/**
|
||||
* Generate a random password.
|
||||
*/
|
||||
export class PasswordGenerator {
|
||||
private readonly lowercaseChars = 'abcdefghijklmnopqrstuvwxyz';
|
||||
private readonly uppercaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
private readonly numberChars = '0123456789';
|
||||
private readonly specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
private readonly ambiguousChars = 'Il1O0';
|
||||
|
||||
private length: number = 18;
|
||||
private useLowercase: boolean = true;
|
||||
private useUppercase: boolean = true;
|
||||
private useNumbers: boolean = true;
|
||||
private useSpecial: boolean = true;
|
||||
private useNonAmbiguous: boolean = false;
|
||||
|
||||
/**
|
||||
* Create a new instance of PasswordGenerator.
|
||||
* @param settings Optional password settings to initialize with.
|
||||
*/
|
||||
public constructor(settings?: PasswordSettings) {
|
||||
if (settings) {
|
||||
this.applySettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply password settings to this generator.
|
||||
*/
|
||||
public applySettings(settings: PasswordSettings): this {
|
||||
this.length = settings.Length;
|
||||
this.useLowercase = settings.UseLowercase;
|
||||
this.useUppercase = settings.UseUppercase;
|
||||
this.useNumbers = settings.UseNumbers;
|
||||
this.useSpecial = settings.UseSpecialChars;
|
||||
this.useNonAmbiguous = settings.UseNonAmbiguousChars;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the length of the password.
|
||||
*/
|
||||
public setLength(length: number): this {
|
||||
this.length = length;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if lowercase letters should be used.
|
||||
*/
|
||||
public useLowercaseLetters(use: boolean): this {
|
||||
this.useLowercase = use;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if uppercase letters should be used.
|
||||
*/
|
||||
public useUppercaseLetters(use: boolean): this {
|
||||
this.useUppercase = use;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if numeric characters should be used.
|
||||
*/
|
||||
public useNumericCharacters(use: boolean): this {
|
||||
this.useNumbers = use;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if special characters should be used.
|
||||
*/
|
||||
public useSpecialCharacters(use: boolean): this {
|
||||
this.useSpecial = use;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if only non-ambiguous characters should be used.
|
||||
*/
|
||||
public useNonAmbiguousCharacters(use: boolean): this {
|
||||
this.useNonAmbiguous = use;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random index from the crypto module.
|
||||
*/
|
||||
private getUnbiasedRandomIndex(max: number): number {
|
||||
// Calculate the largest multiple of max that fits within Uint32.
|
||||
const limit = Math.floor((2 ** 32) / max) * max;
|
||||
|
||||
while (true) {
|
||||
const array = new Uint32Array(1);
|
||||
crypto.getRandomValues(array);
|
||||
const value = array[0];
|
||||
|
||||
// Reject values that would introduce bias.
|
||||
if (value < limit) {
|
||||
return value % max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random password.
|
||||
*/
|
||||
public generateRandomPassword(): string {
|
||||
// Build the character set based on settings
|
||||
const chars = this.buildCharacterSet();
|
||||
|
||||
// Generate initial password.
|
||||
let password = this.generateInitialPassword(chars);
|
||||
|
||||
// Ensure a character from each set is present as some websites require this.
|
||||
password = this.ensureRequirements(password);
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build character set based on selected options.
|
||||
*/
|
||||
private buildCharacterSet(): string {
|
||||
let chars = '';
|
||||
|
||||
if (this.useLowercase) {
|
||||
chars += this.lowercaseChars;
|
||||
}
|
||||
|
||||
if (this.useUppercase) {
|
||||
chars += this.uppercaseChars;
|
||||
}
|
||||
|
||||
if (this.useNumbers) {
|
||||
chars += this.numberChars;
|
||||
}
|
||||
|
||||
if (this.useSpecial) {
|
||||
chars += this.specialChars;
|
||||
}
|
||||
|
||||
// Ensure at least one character set is selected, otherwise default to lowercase.
|
||||
if (chars.length === 0) {
|
||||
chars = this.lowercaseChars;
|
||||
}
|
||||
|
||||
// Remove ambiguous characters if needed.
|
||||
if (this.useNonAmbiguous) {
|
||||
chars = this.removeAmbiguousCharacters(chars);
|
||||
}
|
||||
|
||||
return chars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ambiguous characters from a character set.
|
||||
*/
|
||||
private removeAmbiguousCharacters(chars: string): string {
|
||||
for (const ambChar of this.ambiguousChars) {
|
||||
chars = chars.replace(ambChar, '');
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate initial random password.
|
||||
*/
|
||||
private generateInitialPassword(chars: string): string {
|
||||
let password = '';
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
password += chars[this.getUnbiasedRandomIndex(chars.length)];
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the generated password meets all specified requirements.
|
||||
*/
|
||||
private ensureRequirements(password: string): string {
|
||||
if (this.useLowercase && !/[a-z]/.exec(password)) {
|
||||
password = this.addCharacterFromSet(
|
||||
password,
|
||||
this.getSafeCharacterSet(this.lowercaseChars, true)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.useUppercase && !/[A-Z]/.exec(password)) {
|
||||
password = this.addCharacterFromSet(
|
||||
password,
|
||||
this.getSafeCharacterSet(this.uppercaseChars, true)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.useNumbers && !/\d/.exec(password)) {
|
||||
password = this.addCharacterFromSet(
|
||||
password,
|
||||
this.getSafeCharacterSet(this.numberChars, false)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.useSpecial && !/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.exec(password)) {
|
||||
password = this.addCharacterFromSet(
|
||||
password,
|
||||
this.specialChars
|
||||
);
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a character set with ambiguous characters removed if needed.
|
||||
*/
|
||||
private getSafeCharacterSet(charSet: string, isAlpha: boolean): string {
|
||||
// If we're not using non-ambiguous characters, just return the original set.
|
||||
if (!this.useNonAmbiguous) {
|
||||
return charSet;
|
||||
}
|
||||
|
||||
let safeSet = charSet;
|
||||
for (const ambChar of this.ambiguousChars) {
|
||||
// For numeric sets, only process numeric ambiguous characters
|
||||
if (!isAlpha && !/\d/.test(ambChar)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let charToRemove = ambChar;
|
||||
|
||||
// Handle case conversion for alphabetic characters.
|
||||
if (isAlpha) {
|
||||
if (charSet === this.lowercaseChars) {
|
||||
charToRemove = ambChar.toLowerCase();
|
||||
} else {
|
||||
charToRemove = ambChar.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
safeSet = safeSet.replace(charToRemove, '');
|
||||
}
|
||||
|
||||
return safeSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a character from the given set at a random position in the password.
|
||||
*/
|
||||
private addCharacterFromSet(password: string, charSet: string): string {
|
||||
const pos = this.getUnbiasedRandomIndex(this.length);
|
||||
const char = charSet[this.getUnbiasedRandomIndex(charSet.length)];
|
||||
|
||||
return password.substring(0, pos) + char + password.substring(pos + 1);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { PasswordGenerator } from '../PasswordGenerator';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
describe('PasswordGenerator', () => {
|
||||
let generator: PasswordGenerator;
|
||||
|
||||
beforeEach(() => {
|
||||
generator = new PasswordGenerator();
|
||||
});
|
||||
|
||||
it('generates password with default settings', () => {
|
||||
const password = generator.generateRandomPassword();
|
||||
|
||||
// Default length is 18
|
||||
expect(password.length).toBe(18);
|
||||
|
||||
// Should contain at least one of each character type by default
|
||||
expect(password).toMatch(/[a-z]/); // lowercase
|
||||
expect(password).toMatch(/[A-Z]/); // uppercase
|
||||
expect(password).toMatch(/[0-9]/); // numbers
|
||||
expect(password).toMatch(/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/); // special
|
||||
});
|
||||
|
||||
it('respects custom length setting', () => {
|
||||
const customLength = 24;
|
||||
const password = generator.setLength(customLength).generateRandomPassword();
|
||||
expect(password.length).toBe(customLength);
|
||||
});
|
||||
|
||||
it('respects lowercase setting', () => {
|
||||
const password = generator.useLowercaseLetters(false).generateRandomPassword();
|
||||
expect(password).not.toMatch(/[a-z]/);
|
||||
});
|
||||
|
||||
it('respects uppercase setting', () => {
|
||||
const password = generator.useUppercaseLetters(false).generateRandomPassword();
|
||||
expect(password).not.toMatch(/[A-Z]/);
|
||||
});
|
||||
|
||||
it('respects numbers setting', () => {
|
||||
const password = generator.useNumericCharacters(false).generateRandomPassword();
|
||||
expect(password).not.toMatch(/[0-9]/);
|
||||
});
|
||||
|
||||
it('respects special characters setting', () => {
|
||||
const password = generator.useSpecialCharacters(false).generateRandomPassword();
|
||||
expect(password).not.toMatch(/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/);
|
||||
});
|
||||
|
||||
it('generates different passwords on subsequent calls', () => {
|
||||
const password1 = generator.generateRandomPassword();
|
||||
const password2 = generator.generateRandomPassword();
|
||||
expect(password1).not.toBe(password2);
|
||||
});
|
||||
|
||||
it('handles minimum character requirements', () => {
|
||||
let hasLower = false;
|
||||
let hasUpper = false;
|
||||
let hasNumber = false;
|
||||
let hasSpecial = false;
|
||||
|
||||
// Generate 20 passwords and check if at least one contains all required characters
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const password = generator.generateRandomPassword();
|
||||
|
||||
hasLower = hasLower || /[a-z]/.test(password);
|
||||
hasUpper = hasUpper || /[A-Z]/.test(password);
|
||||
hasNumber = hasNumber || /[0-9]/.test(password);
|
||||
hasSpecial = hasSpecial || /[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.test(password);
|
||||
|
||||
// Break early if we've found all character types
|
||||
if (hasLower && hasUpper && hasNumber && hasSpecial) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert that we found at least one password with each character type
|
||||
expect(hasLower).toBe(true);
|
||||
expect(hasUpper).toBe(true);
|
||||
expect(hasNumber).toBe(true);
|
||||
expect(hasSpecial).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to lowercase when all options disabled', () => {
|
||||
const password = generator
|
||||
.useLowercaseLetters(false)
|
||||
.useUppercaseLetters(false)
|
||||
.useNumericCharacters(false)
|
||||
.useSpecialCharacters(false)
|
||||
.generateRandomPassword();
|
||||
|
||||
// Should fall back to lowercase
|
||||
expect(password).toMatch(/^[a-z]+$/);
|
||||
});
|
||||
|
||||
it('maintains method chaining', () => {
|
||||
const result = generator
|
||||
.setLength(20)
|
||||
.useLowercaseLetters(true)
|
||||
.useUppercaseLetters(true)
|
||||
.useNumericCharacters(true)
|
||||
.useSpecialCharacters(true);
|
||||
|
||||
expect(result).toBe(generator);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user