Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 5 additions & 11 deletions app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)
}))
}));
};
Expand Down Expand Up @@ -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)
}));

Expand Down
31 changes: 16 additions & 15 deletions components/EncryptionModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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;
}

Expand Down Expand Up @@ -75,10 +75,10 @@ export function EncryptionSetupModal({ open, onClose, onSetup }) {
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Master password (min 8 characters)"
placeholder={`Master password (min ${LIMITS.MASTER_PASSWORD_MIN} characters)`}
value={password}
onChange={(e) => setPassword(e.target.value)}
maxLength={LIMITS.MASTER_PASSWORD}
maxLength={LIMITS.MASTER_PASSWORD_MAX}
className="pr-10"
autoComplete="new-password"
/>
Expand All @@ -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 && (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
/>
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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"
/>
Expand All @@ -419,18 +420,18 @@ export function ChangePasswordModal({ open, onClose, onChange }) {

<Input
type={showPasswords ? 'text' : 'password'}
placeholder="New password (min 8 characters)"
placeholder={`New password (min ${LIMITS.MASTER_PASSWORD_MIN} characters)`}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
maxLength={LIMITS.MASTER_PASSWORD}
maxLength={LIMITS.MASTER_PASSWORD_MAX}
/>

<Input
type={showPasswords ? 'text' : 'password'}
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
maxLength={LIMITS.MASTER_PASSWORD}
maxLength={LIMITS.MASTER_PASSWORD_MAX}
/>

{error && (
Expand Down
1 change: 0 additions & 1 deletion components/ProjectModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 1 addition & 6 deletions components/SnippetCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
4 changes: 3 additions & 1 deletion lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion lib/crypto.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down
23 changes: 23 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -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;
}