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 Password Validation for Defense in Depth
**Vulnerability:** Risk of users choosing weak master passwords, undermining the security of XSalsa20-Poly1305 encryption.
**Learning:** Security standards evolve faster than user habits. Enforcing a new, higher minimum length (12 chars) in the UI while maintaining a lower "legacy" minimum (8 chars) in the crypto library allows for improved security for new users without locking out existing ones.
**Prevention:** Always implement a tiered validation approach: strict in the UI for new data/actions, and defense-in-depth in the core logic that preserves backward compatibility for existing data.
24 changes: 18 additions & 6 deletions components/EncryptionModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ export function EncryptionSetupModal({ open, onClose, onSetup }) {
const handleSetup = async () => {
setError('');

if (password.length < 8) {
setError('Password must be at least 8 characters');
if (password.length < LIMITS.MASTER_PASSWORD_MIN) {
setError(`Password must be at least ${LIMITS.MASTER_PASSWORD_MIN} characters`);
return;
}

const uniqueChars = new Set(password).size;
if (uniqueChars < 4) {
setError('Password must contain at least 4 unique characters');
return;
}

Expand Down Expand Up @@ -75,7 +81,7 @@ 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}
Expand Down Expand Up @@ -355,8 +361,14 @@ export function ChangePasswordModal({ open, onClose, onChange }) {
const handleChange = async () => {
setError('');

if (newPassword.length < 8) {
setError('New password must be at least 8 characters');
if (newPassword.length < LIMITS.MASTER_PASSWORD_MIN) {
setError(`New password must be at least ${LIMITS.MASTER_PASSWORD_MIN} characters`);
return;
}

const uniqueChars = new Set(newPassword).size;
if (uniqueChars < 4) {
setError('New password must contain at least 4 unique characters');
return;
}

Expand Down Expand Up @@ -419,7 +431,7 @@ 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}
Expand Down
2 changes: 2 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const LIMITS = {
FIELD_LABEL: 50,
FIELD_VALUE: 10000,
MASTER_PASSWORD: 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 legacy minimum length
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 legacy minimum length
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