From f8d5ae61076da7fbe9c9f38712fc4883385550ee Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 1 May 2025 13:03:06 +0200 Subject: [PATCH] Implement identity and password generator in mobile app (#771) --- .../app/(tabs)/credentials/add-edit.tsx | 76 +++-- apps/mobile-app/context/DbContext.tsx | 2 +- apps/mobile-app/utils/SqliteClient.tsx | 55 +++- .../Identity/UsernameEmailGenerator.ts | 130 --------- .../__tests__/IdentityGenerator.test.ts | 54 ---- .../implementations/IdentityGeneratorEn.ts | 39 --- .../implementations/IdentityGeneratorNl.ts | 39 --- .../base/BaseIdentityGenerator.ts | 75 ----- .../Identity/interfaces/IIdentityGenerator.ts | 5 - .../utils/generators/Identity/types/Gender.ts | 5 - .../generators/Identity/types/Identity.ts | 13 - .../generators/Password/PasswordGenerator.ts | 259 ------------------ .../__tests__/PasswordGenerator.test.ts | 106 ------- 13 files changed, 104 insertions(+), 754 deletions(-) delete mode 100644 apps/mobile-app/utils/generators/Identity/UsernameEmailGenerator.ts delete mode 100644 apps/mobile-app/utils/generators/Identity/__tests__/IdentityGenerator.test.ts delete mode 100644 apps/mobile-app/utils/generators/Identity/implementations/IdentityGeneratorEn.ts delete mode 100644 apps/mobile-app/utils/generators/Identity/implementations/IdentityGeneratorNl.ts delete mode 100644 apps/mobile-app/utils/generators/Identity/implementations/base/BaseIdentityGenerator.ts delete mode 100644 apps/mobile-app/utils/generators/Identity/interfaces/IIdentityGenerator.ts delete mode 100644 apps/mobile-app/utils/generators/Identity/types/Gender.ts delete mode 100644 apps/mobile-app/utils/generators/Identity/types/Identity.ts delete mode 100644 apps/mobile-app/utils/generators/Password/PasswordGenerator.ts delete mode 100644 apps/mobile-app/utils/generators/Password/__tests__/PasswordGenerator.test.ts diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx index 1759c5150..4d98d79f2 100644 --- a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -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 => { + 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, + 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 () => { diff --git a/apps/mobile-app/context/DbContext.tsx b/apps/mobile-app/context/DbContext.tsx index 86f99116d..f972c7c9b 100644 --- a/apps/mobile-app/context/DbContext.tsx +++ b/apps/mobile-app/context/DbContext.tsx @@ -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 ( diff --git a/apps/mobile-app/utils/SqliteClient.tsx b/apps/mobile-app/utils/SqliteClient.tsx index b00d72a6f..77db4b328 100644 --- a/apps/mobile-app/utils/SqliteClient.tsx +++ b/apps/mobile-app/utils/SqliteClient.tsx @@ -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 { + 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 { - return this.getSetting('DefaultEmailDomain'); - } - /** * Get the default identity language from the database. */ diff --git a/apps/mobile-app/utils/generators/Identity/UsernameEmailGenerator.ts b/apps/mobile-app/utils/generators/Identity/UsernameEmailGenerator.ts deleted file mode 100644 index 3993209ec..000000000 --- a/apps/mobile-app/utils/generators/Identity/UsernameEmailGenerator.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/mobile-app/utils/generators/Identity/__tests__/IdentityGenerator.test.ts b/apps/mobile-app/utils/generators/Identity/__tests__/IdentityGenerator.test.ts deleted file mode 100644 index 416926251..000000000 --- a/apps/mobile-app/utils/generators/Identity/__tests__/IdentityGenerator.test.ts +++ /dev/null @@ -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()); -}); \ No newline at end of file diff --git a/apps/mobile-app/utils/generators/Identity/implementations/IdentityGeneratorEn.ts b/apps/mobile-app/utils/generators/Identity/implementations/IdentityGeneratorEn.ts deleted file mode 100644 index b43e3a623..000000000 --- a/apps/mobile-app/utils/generators/Identity/implementations/IdentityGeneratorEn.ts +++ /dev/null @@ -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__'; - } -} diff --git a/apps/mobile-app/utils/generators/Identity/implementations/IdentityGeneratorNl.ts b/apps/mobile-app/utils/generators/Identity/implementations/IdentityGeneratorNl.ts deleted file mode 100644 index 53ea8b958..000000000 --- a/apps/mobile-app/utils/generators/Identity/implementations/IdentityGeneratorNl.ts +++ /dev/null @@ -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__'; - } -} diff --git a/apps/mobile-app/utils/generators/Identity/implementations/base/BaseIdentityGenerator.ts b/apps/mobile-app/utils/generators/Identity/implementations/base/BaseIdentityGenerator.ts deleted file mode 100644 index 9453a4ac4..000000000 --- a/apps/mobile-app/utils/generators/Identity/implementations/base/BaseIdentityGenerator.ts +++ /dev/null @@ -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 { - 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; - } -} \ No newline at end of file diff --git a/apps/mobile-app/utils/generators/Identity/interfaces/IIdentityGenerator.ts b/apps/mobile-app/utils/generators/Identity/interfaces/IIdentityGenerator.ts deleted file mode 100644 index 437622b51..000000000 --- a/apps/mobile-app/utils/generators/Identity/interfaces/IIdentityGenerator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Identity } from "../types/Identity"; - -export interface IIdentityGenerator { - generateRandomIdentity(): Promise; -} diff --git a/apps/mobile-app/utils/generators/Identity/types/Gender.ts b/apps/mobile-app/utils/generators/Identity/types/Gender.ts deleted file mode 100644 index 631f6e3c8..000000000 --- a/apps/mobile-app/utils/generators/Identity/types/Gender.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum Gender { - Male = 'Male', - Female = 'Female', - Other = 'Other' - } diff --git a/apps/mobile-app/utils/generators/Identity/types/Identity.ts b/apps/mobile-app/utils/generators/Identity/types/Identity.ts deleted file mode 100644 index 738b8ee83..000000000 --- a/apps/mobile-app/utils/generators/Identity/types/Identity.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Gender } from './Gender'; - -/** - * Identity. - */ -export type Identity = { - firstName: string; - lastName: string; - gender: Gender; - birthDate: Date; - emailPrefix: string; - nickName: string; - } \ No newline at end of file diff --git a/apps/mobile-app/utils/generators/Password/PasswordGenerator.ts b/apps/mobile-app/utils/generators/Password/PasswordGenerator.ts deleted file mode 100644 index 145e80839..000000000 --- a/apps/mobile-app/utils/generators/Password/PasswordGenerator.ts +++ /dev/null @@ -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); - } -} diff --git a/apps/mobile-app/utils/generators/Password/__tests__/PasswordGenerator.test.ts b/apps/mobile-app/utils/generators/Password/__tests__/PasswordGenerator.test.ts deleted file mode 100644 index 385f39dc2..000000000 --- a/apps/mobile-app/utils/generators/Password/__tests__/PasswordGenerator.test.ts +++ /dev/null @@ -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); - }); -}); \ No newline at end of file