diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index e5a3dcc..399d9e9 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -9,3 +9,8 @@
**Vulnerability:** Missing input length limits on snippets and projects, leading to potential client-side DoS or memory exhaustion.
**Learning:** In a client-side application where all data is kept in memory (and localStorage), excessively large inputs can degrade performance or crash the browser. Input validation must happen at both the UI level and during data ingestion (imports).
**Prevention:** Implement `maxLength` on all user-facing inputs and enforce the same limits in JSON parsing/import logic to ensure data stays within safe bounds.
+
+## 2025-05-16 - Tiered Master Password Validation
+**Vulnerability:** Weak master password requirements (8 characters) allowed easily guessable passwords for encryption.
+**Learning:** Increasing security requirements for existing users can be disruptive. A tiered approach allows for stronger requirements for new/changed passwords (12 chars + complexity check) while maintaining a lower "legacy" minimum (8 chars) in the cryptographic layer to ensure existing users aren't locked out of their data until they choose to update their password.
+**Prevention:** Enforce strict validation in the UI for all write/change operations, but keep a broader compatibility range in the core security libraries to support legacy data. Implement a unique character count check (e.g., min 4 unique characters) to prevent simple repeating patterns that meet length requirements but offer low entropy.
diff --git a/app/page.js b/app/page.js
index f47d6bc..e2dd25b 100644
--- a/app/page.js
+++ b/app/page.js
@@ -25,13 +25,7 @@ import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useTheme } from '@/hooks/useTheme';
import { defaultSnippets } from '@/data/defaultSnippets';
import { cn } from '@/lib/utils';
-import {
- PROJECT_NAME_MAX_LENGTH,
- SNIPPET_TITLE_MAX_LENGTH,
- FIELD_LABEL_MAX_LENGTH,
- FIELD_VALUE_MAX_LENGTH,
- LIMITS
-} from '@/lib/constants';
+import { LIMITS } from '@/lib/constants';
// Generate unique ID
const generateId = () => crypto.randomUUID();
@@ -218,11 +212,11 @@ function HomeContent() {
const sanitizeSnippets = (snippets) => {
return snippets.map(snippet => ({
...snippet,
- title: (snippet.title || '').substring(0, SNIPPET_TITLE_MAX_LENGTH),
+ title: (snippet.title || '').substring(0, LIMITS.SNIPPET_TITLE),
fields: (snippet.fields || []).map(field => ({
...field,
- label: (field.label || '').substring(0, FIELD_LABEL_MAX_LENGTH),
- value: (field.value || '').substring(0, FIELD_VALUE_MAX_LENGTH)
+ label: (field.label || '').substring(0, LIMITS.FIELD_LABEL),
+ value: (field.value || '').substring(0, LIMITS.FIELD_VALUE)
}))
}));
};
@@ -279,7 +273,7 @@ function HomeContent() {
// Sanitize projects
const sanitizedProjects = imported.projects.map(project => ({
...project,
- name: project.name.substring(0, PROJECT_NAME_MAX_LENGTH),
+ name: project.name.substring(0, LIMITS.PROJECT_NAME),
snippets: sanitizeSnippets(project.snippets)
}));
diff --git a/components/EncryptionModal.jsx b/components/EncryptionModal.jsx
index b5a72c0..613ccfa 100644
--- a/components/EncryptionModal.jsx
+++ b/components/EncryptionModal.jsx
@@ -4,7 +4,6 @@ import React, { useState } from 'react';
import { Lock, Shield, ShieldOff, Eye, EyeOff, AlertTriangle, Key } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { MASTER_PASSWORD_MAX_LENGTH } from '@/lib/constants';
import {
Dialog,
DialogContent,
@@ -13,7 +12,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { cn } from '@/lib/utils';
+import { cn, validateMasterPassword } from '@/lib/utils';
import { LIMITS } from '@/lib/constants';
// Setup encryption for first time
@@ -27,8 +26,9 @@ export function EncryptionSetupModal({ open, onClose, onSetup }) {
const handleSetup = async () => {
setError('');
- if (password.length < 8) {
- setError('Password must be at least 8 characters');
+ const validationError = validateMasterPassword(password, 'setup');
+ if (validationError) {
+ setError(validationError);
return;
}
@@ -75,10 +75,10 @@ export function EncryptionSetupModal({ open, onClose, onSetup }) {
setPassword(e.target.value)}
- maxLength={LIMITS.MASTER_PASSWORD}
+ maxLength={LIMITS.MASTER_PASSWORD_MAX}
className="pr-10"
autoComplete="new-password"
/>
@@ -96,7 +96,7 @@ export function EncryptionSetupModal({ open, onClose, onSetup }) {
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
- maxLength={LIMITS.MASTER_PASSWORD}
+ maxLength={LIMITS.MASTER_PASSWORD_MAX}
/>
{error && (
@@ -206,7 +206,7 @@ export function EncryptionUnlockModal({ open, onUnlock }) {
value={password}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
- maxLength={LIMITS.MASTER_PASSWORD}
+ maxLength={LIMITS.MASTER_PASSWORD_MAX}
className="pr-10"
autoFocus
autoComplete="current-password"
@@ -293,7 +293,7 @@ export function DisableEncryptionModal({ open, onClose, onDisable }) {
placeholder="Current password"
value={password}
onChange={(e) => setPassword(e.target.value)}
- maxLength={LIMITS.MASTER_PASSWORD}
+ maxLength={LIMITS.MASTER_PASSWORD_MAX}
className="pr-10"
autoComplete="current-password"
/>
@@ -355,8 +355,9 @@ export function ChangePasswordModal({ open, onClose, onChange }) {
const handleChange = async () => {
setError('');
- if (newPassword.length < 8) {
- setError('New password must be at least 8 characters');
+ const validationError = validateMasterPassword(newPassword, 'change');
+ if (validationError) {
+ setError(validationError);
return;
}
@@ -404,7 +405,7 @@ export function ChangePasswordModal({ open, onClose, onChange }) {
placeholder="Current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
- maxLength={LIMITS.MASTER_PASSWORD}
+ maxLength={LIMITS.MASTER_PASSWORD_MAX}
className="pr-10"
autoComplete="current-password"
/>
@@ -419,10 +420,10 @@ export function ChangePasswordModal({ open, onClose, onChange }) {
setNewPassword(e.target.value)}
- maxLength={LIMITS.MASTER_PASSWORD}
+ maxLength={LIMITS.MASTER_PASSWORD_MAX}
/>
setConfirmPassword(e.target.value)}
- maxLength={LIMITS.MASTER_PASSWORD}
+ maxLength={LIMITS.MASTER_PASSWORD_MAX}
/>
{error && (
diff --git a/components/ProjectModal.jsx b/components/ProjectModal.jsx
index 3dfffea..8c423b1 100644
--- a/components/ProjectModal.jsx
+++ b/components/ProjectModal.jsx
@@ -4,7 +4,6 @@ import React, { useState, useEffect } from 'react';
import { Folder, Copy, Trash2, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { PROJECT_NAME_MAX_LENGTH } from '@/lib/constants';
import {
Dialog,
DialogContent,
diff --git a/components/SnippetCard.jsx b/components/SnippetCard.jsx
index b1e66ac..b75e17f 100644
--- a/components/SnippetCard.jsx
+++ b/components/SnippetCard.jsx
@@ -31,12 +31,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
-import {
- SNIPPET_TITLE_MAX_LENGTH,
- FIELD_LABEL_MAX_LENGTH,
- FIELD_VALUE_MAX_LENGTH,
- LIMITS
-} from '@/lib/constants';
+import { LIMITS } from '@/lib/constants';
const FIELD_TYPES = [
{ value: 'text', label: 'Text', icon: Type },
diff --git a/lib/constants.js b/lib/constants.js
index 1cd3a86..21c0538 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -9,7 +9,9 @@ export const LIMITS = {
SNIPPET_TITLE: 100,
FIELD_LABEL: 50,
FIELD_VALUE: 10000,
- MASTER_PASSWORD: 128,
+ MASTER_PASSWORD_MAX: 128,
+ MASTER_PASSWORD_MIN: 12,
+ MASTER_PASSWORD_LEGACY_MIN: 8,
MAX_PROJECTS: 50,
MAX_SNIPPETS_PER_PROJECT: 200,
MAX_FIELDS_PER_SNIPPET: 20,
diff --git a/lib/crypto.js b/lib/crypto.js
index 2994db3..e698488 100644
--- a/lib/crypto.js
+++ b/lib/crypto.js
@@ -1,6 +1,6 @@
import nacl from 'tweetnacl';
import naclUtil from 'tweetnacl-util';
-import { SECURITY } from './constants';
+import { SECURITY, LIMITS } from './constants';
const { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } = naclUtil;
// Constants
@@ -57,6 +57,11 @@ export function generateNonce() {
* Returns base64 encoded: salt + nonce + ciphertext
*/
export async function encrypt(plaintext, password) {
+ // Defense-in-depth: enforce minimum length even if UI check is bypassed
+ if (!password || password.length < LIMITS.MASTER_PASSWORD_LEGACY_MIN) {
+ throw new Error(`Password must be at least ${LIMITS.MASTER_PASSWORD_LEGACY_MIN} characters`);
+ }
+
const salt = generateSalt();
const key = await deriveKey(password, salt);
const nonce = generateNonce();
@@ -114,6 +119,11 @@ export async function decrypt(encryptedData, password) {
* This is stored to verify the password is correct without storing it
*/
export async function createPasswordHash(password) {
+ // Defense-in-depth: enforce minimum length even if UI check is bypassed
+ if (!password || password.length < LIMITS.MASTER_PASSWORD_LEGACY_MIN) {
+ throw new Error(`Password must be at least ${LIMITS.MASTER_PASSWORD_LEGACY_MIN} characters`);
+ }
+
const salt = generateSalt();
const key = await deriveKey(password, salt);
diff --git a/lib/utils.js b/lib/utils.js
index b20bf01..5b1cb47 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -1,6 +1,29 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
+import { LIMITS } from "./constants";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+
+/**
+ * Validates a master password against security requirements
+ * @param {string} password - The password to validate
+ * @param {string} type - 'setup' or 'change' (for error message flavoring)
+ * @returns {string|null} - Error message or null if valid
+ */
+export function validateMasterPassword(password, type = 'setup') {
+ const prefix = type === 'change' ? 'New password' : 'Password';
+
+ if (!password || password.length < LIMITS.MASTER_PASSWORD_MIN) {
+ return `${prefix} must be at least ${LIMITS.MASTER_PASSWORD_MIN} characters`;
+ }
+
+ // Check for unique characters (at least 4) to prevent simple repeating patterns
+ const uniqueChars = new Set(password).size;
+ if (uniqueChars < 4) {
+ return `${prefix} is too simple. Use more unique characters.`;
+ }
+
+ return null;
+}