Skip to content
Merged
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ See `docs/implementation-plan/README.md` for the full 36-step plan.
- Step 32 (Key Rotation + UI) — complete
- Step 33 (Mobile Responsive Refinements) — complete
- Step 34 (Loading States, Error Boundaries, Toast Notifications) — complete
- Step 35 (Security Hardening) — complete

### Implementation Notes

Expand Down
9 changes: 3 additions & 6 deletions docs/implementation-plan/09-phase-9-polish.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@

---

## Step 35 — Security Hardening
## Step 35 — Security Hardening

**Goal:** Defense-in-depth security measures for an E2EE app.

Expand All @@ -64,11 +64,8 @@
- Zero-fill all key material when vault is locked (already in crypto-store)
- Zero-fill key material after use in crypto functions (best effort — JS GC is not guaranteed)
- Avoid `string` for sensitive data where possible (use `Uint8Array`)
- CSP headers (in `vite.config.ts` or deployment config):
- `Content-Security-Policy`: no inline scripts, no eval, strict origins
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Referrer-Policy: no-referrer`
- Referrer-Policy added as `<meta>` tag in `index.html`
- Remaining security headers (Content-Security-Policy, X-Content-Type-Options, X-Frame-Options) are deployment config — not set at build time
- Supabase RLS audit:
- Verify every table has RLS enabled
- Verify policies enforce `user_id = auth.uid()` (using the internal Supabase email mapping)
Expand Down
2 changes: 1 addition & 1 deletion docs/implementation-plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta
- [x] Step 32 — Key Rotation + UI
- [x] Step 33 — Mobile Responsive Refinements
- [x] Step 34 — Loading States, Error Boundaries, Toast Notifications
- [ ] Step 35 — Security Hardening
- [x] Step 35 — Security Hardening
- [ ] Step 36 — E2E Tests (Playwright)

---
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="referrer" content="no-referrer" />
<script>
;(function () {
var redirect = sessionStorage.redirect
Expand Down
2 changes: 2 additions & 0 deletions src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { OfflineBanner } from '@/shared/ui/OfflineBanner'
import { useLayoutStore } from './layout-store'
import { useResizable } from '@/shared/lib/use-resizable'
import { useVaultTimeout } from '@/features/vault/model/use-vault-timeout'
import { useVaultVisibilityLock } from '@/features/vault/model/use-vault-visibility-lock'
import { logoutUser } from '@/features/auth/model/auth-service'
import { useRealtimeSync } from '@/features/fields/model/use-realtime-sync'
import { useNavigationBlocker } from '@/features/fields/model/use-navigation-blocker'
Expand Down Expand Up @@ -50,6 +51,7 @@ function AuthenticatedLayout() {
})

useVaultTimeout()
useVaultVisibilityLock()
useRealtimeSync()
useBlocker({
shouldBlockFn: () => !navigator.onLine,
Expand Down
1 change: 1 addition & 0 deletions src/app/layouts/layout-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const useLayoutStore = create<LayoutState & LayoutActions>()(
}),
{
name: 'cipher-note-layout',
version: 0,
partialize: (state) => ({
sidebarOpen: state.sidebarOpen,
sidebarWidth: state.sidebarWidth,
Expand Down
25 changes: 23 additions & 2 deletions src/features/auth/model/mnemonic-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ vi.mock('@/shared/crypto/keys/mnemonic', () => ({
recoveryData: mockRecoveryData,
}),
unwrapMasterKeyWithRecovery: vi.fn(),
validateMnemonic: vi.fn().mockResolvedValue(true),
}))

vi.mock('@/shared/crypto/keys/split-kdf', () => ({
Expand Down Expand Up @@ -155,7 +156,7 @@ import {
regenerateMnemonic,
RecoveryLoginError,
} from '@/features/auth/model/mnemonic-service'
import { createRecoveryData, unwrapMasterKeyWithRecovery } from '@/shared/crypto/keys/mnemonic'
import { createRecoveryData, unwrapMasterKeyWithRecovery, validateMnemonic } from '@/shared/crypto/keys/mnemonic'
import { unwrapMasterKeyWithPassword, wrapMasterKeyWithPassword } from '@/shared/crypto/keys/master-key'
import { derivePasswordKey, deriveAuthCredentials } from '@/shared/crypto/keys/split-kdf'
import {
Expand All @@ -169,7 +170,7 @@ import { hexEncode, generateSalt, zeroFill } from '@/shared/crypto/core/crypto-u
import { useAuthStore } from '@/features/auth/model/auth-store'
import { useCryptoStore } from '@/shared/crypto/vault/crypto-store'
import { authAdapter } from '@/shared/auth/supabase-adapter'
import { DecryptionError } from '@/shared/crypto/core/errors'
import { DecryptionError, MnemonicError } from '@/shared/crypto/core/errors'
import { ApiError, ApiErrorCode } from '@/shared/api/api-errors'
import { keyVault } from '@/shared/crypto/vault/key-vault'

Expand Down Expand Up @@ -307,6 +308,17 @@ describe('recoveryFlow.validateMnemonic', () => {
expect(unwrapMasterKeyWithRecovery).not.toHaveBeenCalled()
})

it('throws MnemonicError and skips RPC when mnemonic fails BIP-39 validation', async () => {
vi.mocked(validateMnemonic).mockResolvedValueOnce(false)

await expect(recoveryFlow.validateMnemonic('testuser', 'not a valid mnemonic')).rejects.toThrow(MnemonicError)

// The wordlist guard runs before any network call, so the rate-limited RPC
// is not burned on malformed input.
expect(fetchRecoveryDataPreAuth).not.toHaveBeenCalled()
expect(unwrapMasterKeyWithRecovery).not.toHaveBeenCalled()
})

it('propagates DecryptionError when mnemonic is wrong', async () => {
mockFetchRecoveryDataPreAuth.mockResolvedValueOnce(mockServerRecoveryData)
vi.mocked(unwrapMasterKeyWithRecovery).mockRejectedValueOnce(new DecryptionError())
Expand Down Expand Up @@ -528,6 +540,15 @@ describe('verifyMnemonic', () => {
await expect(verifyMnemonic(mockMnemonic)).rejects.toThrow('No authenticated user')
})

it('throws MnemonicError and skips RPC when mnemonic fails BIP-39 validation', async () => {
vi.mocked(validateMnemonic).mockResolvedValueOnce(false)

await expect(verifyMnemonic('not a valid mnemonic')).rejects.toThrow(MnemonicError)

expect(fetchRecoveryData).not.toHaveBeenCalled()
expect(unwrapMasterKeyWithRecovery).not.toHaveBeenCalled()
})

it('throws when user has no recovery data', async () => {
mockFetchRecoveryData.mockResolvedValueOnce(null)

Expand Down
16 changes: 14 additions & 2 deletions src/features/auth/model/mnemonic-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import {
fetchRecoveryDataPreAuth,
recoverAccount,
} from '@/shared/api/supabase-recovery'
import { createRecoveryData, unwrapMasterKeyWithRecovery } from '@/shared/crypto/keys/mnemonic'
import {
createRecoveryData,
unwrapMasterKeyWithRecovery,
validateMnemonic as validateBip39Mnemonic,
} from '@/shared/crypto/keys/mnemonic'
import { unwrapMasterKeyWithPassword, wrapMasterKeyWithPassword } from '@/shared/crypto/keys/master-key'
import { derivePasswordKey, deriveAuthCredentials } from '@/shared/crypto/keys/split-kdf'
import { hexDecode, hexEncode, generateSalt, zeroFill } from '@/shared/crypto/core/crypto-utils'
import { keyVault } from '@/shared/crypto/vault/key-vault'
import { authAdapter } from '@/shared/auth/supabase-adapter'
import { DecryptionError } from '@/shared/crypto/core/errors'
import { DecryptionError, MnemonicError } from '@/shared/crypto/core/errors'

/**
* Thrown when account recovery succeeded (password changed on server)
Expand Down Expand Up @@ -89,6 +93,10 @@ class RecoveryFlow {
* @throws ApiError(NOT_FOUND) if the account has no recovery data
*/
async validateMnemonic(username: string, mnemonic: string): Promise<void> {
if (!(await validateBip39Mnemonic(mnemonic))) {
throw new MnemonicError()
}

const recoveryData = await fetchRecoveryDataPreAuth(username)

const { masterKey, recoveryAuthHash } = await unwrapMasterKeyWithRecovery(
Expand Down Expand Up @@ -172,6 +180,10 @@ export async function verifyMnemonic(mnemonic: string): Promise<boolean> {
const { user } = useAuthStore.getState()
if (!user) throw new Error('No authenticated user')

if (!(await validateBip39Mnemonic(mnemonic))) {
throw new MnemonicError()
}

const recoveryData = await fetchRecoveryData(user.id)
if (!recoveryData) {
throw new Error('No recovery data found for user')
Expand Down
102 changes: 102 additions & 0 deletions src/features/auth/model/security-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import { registerSchema } from '@/features/auth/model/register-schema'
import { loginSchema } from '@/features/auth/model/login-schema'
import { USERNAME_PATTERN } from '@/shared/auth/username-utils'
import { PASSWORD_MIN_LENGTH } from '@/shared/auth/password-utils'

// Field content (title, note, website, email) is intentionally NOT input-validated:
// it is encrypted client-side and stored as ciphertext, so the server never sees
// plaintext and the DB cannot be queried for it. XSS in field values is mitigated
// by React's text-only rendering — there is no `dangerouslySetInnerHTML` anywhere
// in src/ (verified by grep in the verification step). These tests cover the
// unencrypted auth inputs (username, password) where validation is security-critical:
// the username is mapped to a Supabase Auth email and used in pre-auth RPCs, so
// rejecting malformed/malicious input at the schema layer prevents injecting
// control characters, SQL-like fragments, or markup into those flows.

describe('security — input validation rejects malicious input', () => {
describe('USERNAME_PATTERN', () => {
const maliciousUsernames = [
"' OR 1=1--",
'admin";--',
'<script>alert(1)</script>',
'user name', // space
'user@name', // special char
'user/name',
'user;drop',
'user.name',
'user-name',
'a'.repeat(33), // overlength
'ab', // underlength
'',
]

for (const username of maliciousUsernames) {
it(`rejects ${JSON.stringify(username)}`, () => {
expect(USERNAME_PATTERN.test(username)).toBe(false)
})
}

const validUsernames = ['abc', 'user_123', 'ABC_def', 'a'.repeat(32)]
for (const username of validUsernames) {
it(`accepts ${JSON.stringify(username)}`, () => {
expect(USERNAME_PATTERN.test(username)).toBe(true)
})
}
})

describe('registerSchema username', () => {
const validPasswords = { password: 'validPassword1', confirmPassword: 'validPassword1' }

it('rejects SQL-injection-style username', async () => {
const result = await registerSchema.safeParseAsync({ username: "' OR 1=1--", ...validPasswords })
expect(result.success).toBe(false)
})

it('rejects XSS payload in username', async () => {
const result = await registerSchema.safeParseAsync({
username: '<script>alert(1)</script>',
...validPasswords,
})
expect(result.success).toBe(false)
})

it('rejects overlength username (>32)', async () => {
const result = await registerSchema.safeParseAsync({ username: 'a'.repeat(33), ...validPasswords })
expect(result.success).toBe(false)
})

it('rejects underlength username (<3)', async () => {
const result = await registerSchema.safeParseAsync({ username: 'ab', ...validPasswords })
expect(result.success).toBe(false)
})

it('rejects username with spaces', async () => {
const result = await registerSchema.safeParseAsync({ username: 'user name', ...validPasswords })
expect(result.success).toBe(false)
})
})

describe('registerSchema password', () => {
it(`rejects password shorter than ${PASSWORD_MIN_LENGTH} characters`, async () => {
const result = await registerSchema.safeParseAsync({
username: 'validuser',
password: 'short',
confirmPassword: 'short',
})
expect(result.success).toBe(false)
})
})

describe('loginSchema username', () => {
it('rejects SQL-injection-style username at login', async () => {
const result = await loginSchema.safeParseAsync({ username: "' OR 1=1--", password: 'anything' })
expect(result.success).toBe(false)
})

it('rejects XSS payload in username at login', async () => {
const result = await loginSchema.safeParseAsync({ username: '<script>alert(1)</script>', password: 'anything' })
expect(result.success).toBe(false)
})
})
})
2 changes: 1 addition & 1 deletion src/features/auth/ui/ChangePasswordDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ vi.mock('@/features/auth/model/auth-service', () => ({

import { ChangePasswordDialog } from './ChangePasswordDialog'
import { changeUserPassword } from '@/features/auth/model/auth-service'
import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store'
import { useChangePasswordDialogStore } from '@/shared/stores/dialogs-store'

const mockChangeUserPassword = vi.mocked(changeUserPassword)

Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/ui/ChangePasswordDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { PasswordStrength } from '@/features/auth/ui/PasswordStrength'
import { changePasswordSchema, type ChangePasswordFormData } from '@/features/auth/model/change-password-schema'
import { changeUserPassword } from '@/features/auth/model/auth-service'
import { getChangePasswordErrorMessage } from '@/features/auth/model/change-password-error-messages'
import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store'
import { useChangePasswordDialogStore } from '@/shared/stores/dialogs-store'

function ChangePasswordDialog() {
const { t } = useTranslation('auth')
Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/ui/RegenerateMnemonicDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ vi.mock('@/features/auth/model/mnemonic-service', () => ({

import { RegenerateMnemonicDialog } from './RegenerateMnemonicDialog'
import { regenerateMnemonic } from '@/features/auth/model/mnemonic-service'
import { useRegenerateMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store'
import { useRegenerateMnemonicDialogStore } from '@/shared/stores/dialogs-store'

const mockRegenerateMnemonic = vi.mocked(regenerateMnemonic)

Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/ui/RegenerateMnemonicDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { toast } from 'sonner'

import { PasswordConfirmDialog } from '@/shared/ui/PasswordConfirmDialog'
import { MnemonicDialog } from '@/features/auth/ui/MnemonicDialog'
import { useRegenerateMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store'
import { useRegenerateMnemonicDialogStore } from '@/shared/stores/dialogs-store'
import { regenerateMnemonic } from '@/features/auth/model/mnemonic-service'
import { getRegenerateMnemonicErrorMessage } from '@/features/auth/model/recovery-error-messages'

Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/ui/VerifyMnemonicDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ vi.mock('@/shared/crypto/keys/mnemonic', () => ({

import { VerifyMnemonicDialog } from './VerifyMnemonicDialog'
import { verifyMnemonic } from '@/features/auth/model/mnemonic-service'
import { useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store'
import { useVerifyMnemonicDialogStore } from '@/shared/stores/dialogs-store'
import { toast } from 'sonner'

const mockVerifyMnemonic = vi.mocked(verifyMnemonic)
Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/ui/VerifyMnemonicDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { MnemonicInput } from '@/features/auth/ui/MnemonicInput'
import { useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store'
import { useVerifyMnemonicDialogStore } from '@/shared/stores/dialogs-store'
import { verifyMnemonic } from '@/features/auth/model/mnemonic-service'
import { getRecoveryErrorMessage } from '@/features/auth/model/recovery-error-messages'

Expand Down
21 changes: 20 additions & 1 deletion src/features/fields/model/field-crypto.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { importKey } from '@/shared/crypto/core/aes-gcm'
import * as cryptoUtils from '@/shared/crypto/core/crypto-utils'
import { DecryptionError } from '@/shared/crypto/core/errors'
import { encryptField, decryptField, toSaveFieldData, toEncryptedFieldData } from '@/features/fields/model/field-crypto'
import { FIELD_NAMES } from '@/shared/types/entities/field.types'
Expand Down Expand Up @@ -64,6 +65,24 @@ describe('encryptField + decryptField', () => {
// Attempt to decrypt as a different field — AAD won't match
await expect(decryptField(encrypted, key, 'website')).rejects.toThrow(DecryptionError)
})

it('zero-fills the decrypted plaintext bytes after decoding', async () => {
const key = await generateKey()
const encrypted = await encryptField('secret note', key, 'note')

const zeroFillSpy = vi.spyOn(cryptoUtils, 'zeroFill')
try {
await decryptField(encrypted, key, 'note')

// decryptField decodes plaintextBytes to a string, then zero-fills the bytes.
expect(zeroFillSpy).toHaveBeenCalledOnce()
const buf = zeroFillSpy.mock.calls[0]![0] as Uint8Array
expect(buf.length).toBe('secret note'.length)
expect(Array.from(buf).every((b) => b === 0)).toBe(true)
} finally {
zeroFillSpy.mockRestore()
}
})
})

describe('toSaveFieldData + toEncryptedFieldData', () => {
Expand Down
6 changes: 4 additions & 2 deletions src/features/fields/model/field-crypto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { decrypt, encrypt } from '@/shared/crypto/core/aes-gcm'
import { encodeAAD, generateIV, hexDecode, hexEncode } from '@/shared/crypto/core/crypto-utils'
import { encodeAAD, generateIV, hexDecode, hexEncode, zeroFill } from '@/shared/crypto/core/crypto-utils'
import { FIELD_CONTENT_VERSION } from '@/shared/types/crypto.types'
import type { EncryptedFieldData } from '@/shared/types/crypto.types'
import type { FieldName } from '@/shared/types/entities/field.types'
Expand Down Expand Up @@ -36,7 +36,9 @@ export async function decryptField(
): Promise<string> {
const aad = encodeAAD(fieldName, FIELD_CONTENT_VERSION)
const plaintextBytes = await decrypt(encryptedData.ciphertext, fieldKey, { iv: encryptedData.ciphertextIV, aad })
return new TextDecoder().decode(plaintextBytes)
const text = new TextDecoder().decode(plaintextBytes)
zeroFill(plaintextBytes)
return text
}

/** Convert internal binary EncryptedFieldData to hex-string SaveFieldData for the API. */
Expand Down
2 changes: 1 addition & 1 deletion src/features/fields/ui/RotateFieldKeyDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ vi.mock('@/features/fields/model/key-rotation-service', () => ({

import { RotateFieldKeyDialog } from './RotateFieldKeyDialog'
import { toast } from 'sonner'
import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store'
import { useRotateFieldKeyDialogStore } from '@/shared/stores/dialogs-store'
import { useAuthStore } from '@/features/auth/model/auth-store'
import { useCryptoStore } from '@/shared/crypto/vault/crypto-store'
import { queryKeys } from '@/shared/lib/query-keys'
Expand Down
2 changes: 1 addition & 1 deletion src/features/fields/ui/RotateFieldKeyDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
AlertDialogTitle,
} from '@/shared/ui/alert-dialog'
import { Spinner } from '@/shared/ui/Spinner'
import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store'
import { useRotateFieldKeyDialogStore } from '@/shared/stores/dialogs-store'
import { useRequiredUserId } from '@/shared/auth/use-current-user'
import { queryKeys } from '@/shared/lib/query-keys'
import { FIELD_NAMES } from '@/shared/types/entities/field.types'
Expand Down
Loading
Loading