From 9d9e599c09077b17add316637a43f7193e42b09c Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Sat, 16 May 2026 23:32:25 +0000 Subject: [PATCH] Fix passwords not being random --- dist/index.html | 7 +++--- src/lib/crypto/crypto.js | 9 ++++--- tests/lib/crypto/crypto.test.js | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 tests/lib/crypto/crypto.test.js diff --git a/dist/index.html b/dist/index.html index f53912e..bbbe821 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5007,12 +5007,12 @@ function isTrashGroup(groupId) { //#endregion //#region src/lib/crypto/crypto.js /** -* Crypto module — Web Crypto API wrapper. +* Crypto module - Web Crypto API wrapper. * * Uses PBKDF2 for key derivation and AES-GCM for symmetric encryption. * All operations are async and use the browser's native Web Crypto API. * -* The derived encryption key is kept in memory only — never written to disk. +* The derived encryption key is kept in memory only - never written to disk. */ var PBKDF2_ITERATIONS = 6e5; var SALT_LENGTH = 16; @@ -5146,10 +5146,11 @@ function generatePassword({ length = 16, uppercase = true, lowercase = true, dig const excludeSet = new Set(exclude.split("")); charset = charset.split("").filter((c) => !excludeSet.has(c)).join(""); } - if (!charset) throw new Error("Password charset is empty — enable at least one character type"); + if (!charset) throw new Error("Password charset is empty - enable at least one character type"); const charsetLength = charset.length; const maxValid = 256 - 256 % charsetLength; const randomBytes = new Uint8Array(length * 2); + crypto.getRandomValues(randomBytes); let password = ""; let byteIdx = 0; while (password.length < length) { diff --git a/src/lib/crypto/crypto.js b/src/lib/crypto/crypto.js index 242e355..60564cc 100644 --- a/src/lib/crypto/crypto.js +++ b/src/lib/crypto/crypto.js @@ -1,10 +1,10 @@ /** - * Crypto module — Web Crypto API wrapper. + * Crypto module - Web Crypto API wrapper. * * Uses PBKDF2 for key derivation and AES-GCM for symmetric encryption. * All operations are async and use the browser's native Web Crypto API. * - * The derived encryption key is kept in memory only — never written to disk. + * The derived encryption key is kept in memory only - never written to disk. */ import { generateId } from '../models/schema.js' @@ -48,7 +48,7 @@ export async function deriveKey(masterPassword, salt) { }, keyMaterial, { name: 'AES-GCM', length: 256 }, - false, // not extractable — stays in memory + false, // not extractable - stays in memory ['encrypt', 'decrypt'] ) } @@ -186,12 +186,13 @@ export function generatePassword({ } if (!charset) { - throw new Error('Password charset is empty — enable at least one character type') + throw new Error('Password charset is empty - enable at least one character type') } const charsetLength = charset.length const maxValid = 256 - (256 % charsetLength) const randomBytes = new Uint8Array(length * 2) + crypto.getRandomValues(randomBytes) let password = '' let byteIdx = 0 diff --git a/tests/lib/crypto/crypto.test.js b/tests/lib/crypto/crypto.test.js new file mode 100644 index 0000000..147cf21 --- /dev/null +++ b/tests/lib/crypto/crypto.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { generatePassword } from '../../../src/lib/crypto/crypto.js' + +describe('generatePassword', () => { + it('should produce different passwords on consecutive calls', () => { + const passwords = new Set() + for (let i = 0; i < 50; i++) { + passwords.add(generatePassword({ length: 16 })) + } + // With 50 calls and 94^16 possible values, all should be unique + expect(passwords.size).toBe(50) + }) + + it('should respect the length option', () => { + for (let len of [4, 8, 16, 32, 64]) { + const pw = generatePassword({ length: len }) + expect(pw.length).toBe(len) + } + }) + + it('should only use allowed character types', () => { + const pw = generatePassword({ uppercase: true, lowercase: false, digits: false, symbols: false }) + expect(pw).toMatch(/^[A-Z]+$/) + + const pw2 = generatePassword({ uppercase: false, lowercase: true, digits: false, symbols: false }) + expect(pw2).toMatch(/^[a-z]+$/) + + const pw3 = generatePassword({ uppercase: false, lowercase: false, digits: true, symbols: false }) + expect(pw3).toMatch(/^[0-9]+$/) + }) + + it('should exclude specified characters', () => { + const pw = generatePassword({ length: 32, exclude: '0OIl1' }) + for (const ch of '0OIl1') { + expect(pw).not.toContain(ch) + } + }) + + it('should throw when charset is empty', () => { + expect(() => generatePassword({ uppercase: false, lowercase: false, digits: false, symbols: false })) + .toThrow() + }) +})