refactor(encryption): strip client-side transaction encryption (#514)

## Why

Transactions and account names used to be stored encrypted client-side.
They now live **unencrypted in the backend**, so the browser no longer
needs to encrypt, decrypt, or mask them. A handful of legacy accounts
may still have encrypted transactions, but the auto-run
decrypt-migration handles those — so this PR strips the now-dead
encryption machinery while keeping the migration path fully intact.

Net: **−1213 / +176** lines across 25 files.

## Removed (dead machinery)

- `setup-encryption` page, `POST /api/encryption/setup`,
`SetupEncryptionRequest`, and `encrypt()` / `generateSalt()` — new data
is never encrypted again
- `EncryptedText` and the decrypt-on-render description component →
replaced by a plaintext `TransactionDescription` that keeps the
privacy-mode fake labels
- All `isKeySet`-gated client decryption: transaction list (load /
search / sort), categorize flow, import dedup
- `isKeySet` masking / locking on amount display, app logo, import
button, add-transaction button, account page, and filters
- The unused `?encrypted=false` API filter and `isKeyPersistent()`

## Kept (legacy decrypt-migration)

- The two migration hooks, the unlock dialog, and the dashboard/login
unlock prompt
- `decrypt` / `importKey` + key derivation, key-storage,
`EncryptedMessage`, salt, `GET /api/encryption/message`,
`?encrypted=true`, and the middleware salt-cleanup
- `rule-engine` / edit / import-drawer account-name decryption —
plaintext-first (`if (!account.encrypted) return name`) legacy fallback
on the kept primitives; intentionally untouched

## Testing

- Pint  · ESLint  · Prettier 
- 184 vitest tests 
- 1425 PHP Feature/Unit tests  (Browser suite not run locally — needs
`bun run build`; no Browser test references the encryption UI)

## Follow-up (out of scope)

`DecryptedTransaction.decryptedDescription` / `decryptedNotes` are now
just plaintext aliases. Collapsing that type touches 13+ files (rule
engine, categorizer, edit dialog) and was left as a separate cleanup.
This commit is contained in:
Víctor Falcón 2026-06-20 18:13:26 +02:00 committed by GitHub
parent 14c4598cda
commit 7fb190683c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 344 additions and 1179 deletions

View File

@ -21,8 +21,6 @@ class TransactionController extends Controller
if ($request->query('encrypted') === 'true') {
$query->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'));
} elseif ($request->query('encrypted') === 'false') {
$query->whereNull('description_iv')->whereNull('notes_iv');
}
$transactions = $query->simplePaginate(100);

View File

@ -2,34 +2,11 @@
namespace App\Http\Controllers;
use App\Http\Requests\SetupEncryptionRequest;
use App\Models\EncryptedMessage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EncryptionController extends Controller
{
public function setup(SetupEncryptionRequest $request): JsonResponse
{
$user = $request->user();
$user->update([
'encryption_salt' => $request->validated('salt'),
]);
EncryptedMessage::query()->updateOrCreate(
['user_id' => $user->id],
[
'encrypted_content' => $request->validated('encrypted_content'),
'iv' => $request->validated('iv'),
]
);
return response()->json([
'message' => 'Encryption setup completed successfully',
]);
}
public function getMessage(Request $request): JsonResponse
{
$user = $request->user();

View File

@ -1,31 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SetupEncryptionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'salt' => ['required', 'string', 'size:24'],
'encrypted_content' => ['required', 'string'],
'iv' => ['required', 'string', 'size:16'],
];
}
}

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Some legacy accounts are still flagged `encrypted = true` even though
* their name is stored in plaintext (`name_iv` is null). The client-side
* decrypt-migration only clears the flag for accounts whose name is
* actually encrypted, so these stale flags would never resolve on their
* own and keep the user perpetually counted as "has encrypted accounts".
* Align the flag with reality: an account with no `name_iv` is not
* encrypted. Transaction decryption is unaffected it is driven by each
* transaction's `description_iv` / `notes_iv`, not by this flag.
*/
public function up(): void
{
DB::table('accounts')
->where('encrypted', true)
->whereNull('name_iv')
->update(['encrypted' => false]);
}
/**
* Irreversible: once flipped, a migration-corrected account is
* indistinguishable from an account that was always plaintext, so blanket
* re-flagging would wrongly encrypt legitimately unencrypted accounts.
*/
public function down(): void
{
//
}
};

View File

@ -1,4 +1,3 @@
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { cn } from '@/lib/utils';
import { BirdIcon, Birdhouse, SVGAttributes } from 'lucide-react';
@ -10,7 +9,6 @@ export default function AppLogoIcon({
className?: SVGAttributes['className'];
animated?: boolean;
}) {
const { isKeySet } = useEncryptionKey();
const { isPrivacyModeEnabled } = usePrivacyMode();
const iconClasses = cn(
@ -19,7 +17,7 @@ export default function AppLogoIcon({
'fill-transparent',
);
const showBirdhouse = !isKeySet || isPrivacyModeEnabled;
const showBirdhouse = isPrivacyModeEnabled;
if (!animated) {
return <BirdIcon className={iconClasses} />;

View File

@ -1,157 +0,0 @@
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { useEffect, useMemo, useRef, useState } from 'react';
type Length = number | { min: number; max: number } | null;
interface EncryptedTextProps {
encryptedText: string;
iv: string;
className?: string;
length: Length;
}
type DisplayState = 'encrypted' | 'decrypted' | 'loading';
const ENCRYPTED_CHARSET = '0123456789$%&#@!ABCDEFGHIJKLMNOPQRSTUVWXYZ';
function resolveTargetLength(length: Length, fallback: number): number {
if (typeof length === 'number') {
return Math.max(1, length);
}
if (length && typeof length === 'object') {
const min = Math.max(1, length.min);
const max = Math.max(min, length.max);
const clampedFallback = Math.min(Math.max(fallback, min), max);
return clampedFallback;
}
return Math.max(1, fallback);
}
function generateMaskedText(targetLength: number): string {
if (targetLength <= 0) {
return '';
}
let result = '';
for (let index = 0; index < targetLength; index += 1) {
const randomIndex = Math.floor(
Math.random() * ENCRYPTED_CHARSET.length,
);
result += ENCRYPTED_CHARSET.charAt(randomIndex);
}
return result;
}
function getInitialDisplayState(isKeySet: boolean): DisplayState {
if (!isKeySet) {
return 'encrypted';
}
if (typeof window === 'undefined') {
return 'encrypted';
}
const keyString = getStoredKey();
return keyString ? 'loading' : 'encrypted';
}
export function EncryptedText(props: EncryptedTextProps) {
const { encryptedText, iv, className = '', length = null } = props;
const { isKeySet } = useEncryptionKey();
const targetLength = useMemo(
() => resolveTargetLength(length, encryptedText.length),
[length, encryptedText.length],
);
const maskedValue = useMemo(
() => generateMaskedText(targetLength),
[targetLength],
);
const [cachedDecryption, setCachedDecryption] = useState<{
encryptedText: string;
iv: string;
value: string;
} | null>(null);
const [displayState, setDisplayState] = useState<DisplayState>(() =>
getInitialDisplayState(isKeySet),
);
const prevIsKeySetRef = useRef(isKeySet);
useEffect(() => {
const wasKeySet = prevIsKeySetRef.current;
prevIsKeySetRef.current = isKeySet;
if (!isKeySet) {
if (wasKeySet) {
setCachedDecryption(null);
setDisplayState('encrypted');
}
return;
}
const keyString = getStoredKey();
if (!keyString) {
if (wasKeySet !== isKeySet) {
setCachedDecryption(null);
setDisplayState('encrypted');
}
return;
}
if (!wasKeySet && isKeySet) {
setDisplayState('loading');
}
let cancelled = false;
importKey(keyString)
.then((key) => decrypt(encryptedText, key, iv))
.then((value) => {
if (cancelled) {
return;
}
setCachedDecryption({ encryptedText, iv, value });
setDisplayState('decrypted');
})
.catch(() => {
if (cancelled) {
return;
}
setCachedDecryption(null);
setDisplayState('encrypted');
});
return () => {
cancelled = true;
};
}, [encryptedText, iv, isKeySet]);
if (displayState === 'loading') {
const widthInCharacters = Math.max(targetLength, 3) / 2;
const loadingClassName = [
'inline-block animate-pulse rounded bg-muted/60',
className,
]
.filter(Boolean)
.join(' ');
return (
<span
className={loadingClassName}
style={{
width: `${widthInCharacters}ch`,
height: '1em',
}}
/>
);
}
if (displayState === 'decrypted' && cachedDecryption) {
return <span className={className}>{cachedDecryption.value}</span>;
}
return <span className={className}>{maskedValue}</span>;
}

View File

@ -1,4 +1,4 @@
import { EncryptedTransactionDescription } from '@/components/transactions/encrypted-transaction-description';
import { TransactionDescription } from '@/components/transactions/transaction-description';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -16,7 +16,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useLocale } from '@/hooks/use-locale';
import { transactionSyncService } from '@/services/transaction-sync';
import { type ParsedTransaction } from '@/types/import';
@ -47,7 +46,6 @@ export function ImportStepPreview({
onSelectAll,
isImporting,
}: ImportStepPreviewProps) {
const { isKeySet } = useEncryptionKey();
const locale = useLocale();
const [existingTransactions, setExistingTransactions] = useState<
Transaction[]
@ -55,7 +53,7 @@ export function ImportStepPreview({
const [isExistingOpen, setIsExistingOpen] = useState(false);
useEffect(() => {
if (accountId && isKeySet) {
if (accountId) {
transactionSyncService.getByAccountId(accountId).then((txns) => {
const sorted = txns.sort(
(a, b) =>
@ -65,7 +63,7 @@ export function ImportStepPreview({
setExistingTransactions(sorted.slice(0, 10));
});
}
}, [accountId, isKeySet]);
}, [accountId]);
const stats = useMemo(() => {
const selectableTransactions = transactions.filter(
@ -304,15 +302,8 @@ export function ImportStepPreview({
)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
<EncryptedTransactionDescription
encryptedText={
tx.description
}
iv={tx.description_iv}
length={{
min: 10,
max: 40,
}}
<TransactionDescription
text={tx.description}
/>
</TableCell>
<TableCell className="text-right font-mono">

View File

@ -5,7 +5,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
@ -23,19 +22,11 @@ interface ImportData {
}
export function ImportTransactionsButton() {
const { isKeySet } = useEncryptionKey();
const [drawerOpen, setDrawerOpen] = useState(false);
const [importData, setImportData] = useState<ImportData | null>(null);
const [loading, setLoading] = useState(false);
const handleOpenDrawer = async () => {
if (!isKeySet) {
toast.error(
__('Please unlock your encryption key to import transactions'),
);
return;
}
// Fetch data on-demand when drawer opens
setLoading(true);
try {
@ -61,7 +52,7 @@ export function ImportTransactionsButton() {
<TooltipTrigger asChild>
<Button
variant="ghost"
className={`h-9 ${!isKeySet || loading ? 'cursor-not-allowed opacity-50' : ''}`}
className={`h-9 ${loading ? 'cursor-not-allowed opacity-50' : ''}`}
onClick={handleOpenDrawer}
disabled={loading}
aria-label={__('Import transactions')}
@ -73,9 +64,7 @@ export function ImportTransactionsButton() {
</Button>
</TooltipTrigger>
<TooltipContent>
{!isKeySet
? __('Unlock encryption to import transactions')
: __('Import transactions from CSV/Excel')}
{__('Import transactions from CSV/Excel')}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@ -10,10 +10,6 @@ vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
categorize: { url: () => '/transactions/categorize' },
}));
vi.mock('@/contexts/encryption-key-context', () => ({
useEncryptionKey: () => ({ isKeySet: true }),
}));
vi.mock('@/hooks/use-mobile', () => ({
useIsMobile: () => false,
}));

View File

@ -13,7 +13,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useIsMobile } from '@/hooks/use-mobile';
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
import { hasActiveFilters } from '@/lib/transaction-filter-serialization';
@ -36,7 +35,6 @@ import {
WandSparkles,
} from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
import { TransactionAnalysisDrawer } from './transaction-analysis-drawer';
@ -63,7 +61,6 @@ export function TransactionActionsMenu({
onImportComplete,
filters,
}: TransactionActionsMenuProps) {
const { isKeySet } = useEncryptionKey();
const { features } = usePage<SharedData>().props;
const isMobile = useIsMobile();
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
@ -85,22 +82,10 @@ export function TransactionActionsMenu({
};
const handleAddTransaction = () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to add transactions',
);
return;
}
onAddTransaction();
};
const handleOpenImportDrawer = () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to import transactions',
);
return;
}
setImportDrawerOpen(true);
};
@ -187,22 +172,20 @@ export function TransactionActionsMenu({
<Button
variant="outline"
className={
!isKeySet || uncategorizedCount === 0
uncategorizedCount === 0
? 'cursor-not-allowed opacity-50'
: ''
}
disabled={!isKeySet || uncategorizedCount === 0}
asChild={isKeySet && uncategorizedCount > 0}
disabled={uncategorizedCount === 0}
asChild={uncategorizedCount > 0}
>
{isKeySet && uncategorizedCount > 0 ? (
{uncategorizedCount > 0 ? (
<Link href={categorize.url()}>
{__('Categorize')}
{uncategorizedCount > 0 && (
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{uncategorizedCount}
</span>
)}
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{uncategorizedCount}
</span>
</Link>
) : (
<>{__('Categorize')}</>
@ -210,11 +193,9 @@ export function TransactionActionsMenu({
</Button>
</TooltipTrigger>
<TooltipContent>
{!isKeySet
? 'Unlock encryption to categorize'
: uncategorizedCount === 0
? 'All transactions are categorized'
: `Categorize ${uncategorizedCount} transactions`}
{uncategorizedCount === 0
? 'All transactions are categorized'
: `Categorize ${uncategorizedCount} transactions`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -225,11 +206,6 @@ export function TransactionActionsMenu({
<TooltipTrigger asChild>
<Button
variant="outline"
className={
!isKeySet
? 'cursor-not-allowed opacity-50'
: ''
}
onClick={handleAddTransaction}
aria-label={__('Add transaction')}
>
@ -240,11 +216,7 @@ export function TransactionActionsMenu({
</Button>
</TooltipTrigger>
<TooltipContent>
{!isKeySet
? __(
'Unlock encryption to add transactions',
)
: __('Create a new transaction')}
{__('Create a new transaction')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -271,18 +243,12 @@ export function TransactionActionsMenu({
</TooltipProvider>
<DropdownMenuContent align="end">
{isMobile && (
<DropdownMenuItem
onClick={handleAddTransaction}
disabled={!isKeySet}
>
<DropdownMenuItem onClick={handleAddTransaction}>
<Plus className="mr-2 h-4 w-4" />
{__('Add transaction')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleOpenImportDrawer}
disabled={!isKeySet}
>
<DropdownMenuItem onClick={handleOpenImportDrawer}>
<Upload className="mr-2 h-4 w-4" />
{__('Import Transactions')}
</DropdownMenuItem>

View File

@ -6,10 +6,12 @@ import { ArrowDown, ArrowUp, ArrowUpDown, MoreHorizontal } from 'lucide-react';
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { EncryptedText } from '@/components/encrypted-text';
import { LabelBadges } from '@/components/shared/label-combobox';
import { CategoryCell } from '@/components/transactions/category-cell';
import { EncryptedTransactionDescription } from '@/components/transactions/encrypted-transaction-description';
import {
ENCRYPTED_PLACEHOLDER,
TransactionDescription,
} from '@/components/transactions/transaction-description';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@ -207,18 +209,15 @@ export function createTransactionColumns({
.filter(Boolean) as Label[];
const hasLabels = transactionLabels.length > 0;
const hasNotes =
transaction.decryptedNotes ||
(transaction.notes && transaction.notes_iv);
const hasNotes = !!transaction.notes;
return (
<div className="flex flex-col gap-0.5">
<div className="flex flex-row justify-between gap-1">
<div className="flex-grow truncate">
<EncryptedTransactionDescription
encryptedText={transaction.description}
iv={transaction.description_iv}
length={{ min: 20, max: 80 }}
<TransactionDescription
text={transaction.description}
encrypted={!!transaction.description_iv}
/>
</div>
{showLabels && hasLabels && (
@ -231,19 +230,11 @@ export function createTransactionColumns({
{showNotes && hasNotes && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="truncate text-muted-foreground/80">
{transaction.decryptedNotes ? (
<span>
{transaction.decryptedNotes}
</span>
) : (
<EncryptedText
encryptedText={
transaction.notes || ''
}
iv={transaction.notes_iv || ''}
length={{ min: 10, max: 30 }}
/>
)}
<span>
{transaction.notes_iv
? ENCRYPTED_PLACEHOLDER
: transaction.notes}
</span>
</div>
</div>
)}

View File

@ -0,0 +1,33 @@
import { PrivacyModeProvider } from '@/contexts/privacy-mode-context';
import { render, screen } from '@testing-library/react';
import type React from 'react';
import { describe, expect, it } from 'vitest';
import {
ENCRYPTED_PLACEHOLDER,
TransactionDescription,
} from './transaction-description';
function renderWithPrivacy(ui: React.ReactElement) {
return render(<PrivacyModeProvider>{ui}</PrivacyModeProvider>);
}
describe('TransactionDescription', () => {
it('renders the plaintext description when not encrypted', () => {
renderWithPrivacy(
<TransactionDescription text="Coffee at Starbucks" />,
);
expect(screen.getByText('Coffee at Starbucks')).toBeInTheDocument();
});
it('masks a still-encrypted value instead of leaking raw ciphertext', () => {
const ciphertext = 'U2FsdGVkX1+abc123def456==';
renderWithPrivacy(
<TransactionDescription text={ciphertext} encrypted />,
);
expect(screen.queryByText(ciphertext)).not.toBeInTheDocument();
expect(screen.getByText(ENCRYPTED_PLACEHOLDER)).toBeInTheDocument();
});
});

View File

@ -1,5 +1,3 @@
import { EncryptedText } from '@/components/encrypted-text';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { useMemo } from 'react';
@ -37,44 +35,34 @@ function getFakeDescription(seed: string): string {
return FAKE_DESCRIPTIONS[index];
}
interface EncryptedTransactionDescriptionProps {
encryptedText: string;
iv: string | null;
/**
* Shown in place of a still-encrypted legacy value before the user unlocks and
* the decrypt-migration runs, so the list never renders raw ciphertext.
*/
export const ENCRYPTED_PLACEHOLDER = '••••••••••';
interface TransactionDescriptionProps {
text: string;
className?: string;
length?: number | { min: number; max: number } | null;
encrypted?: boolean;
}
export function EncryptedTransactionDescription({
encryptedText,
iv,
export function TransactionDescription({
text,
className = '',
length = null,
}: EncryptedTransactionDescriptionProps) {
const { isKeySet } = useEncryptionKey();
encrypted = false,
}: TransactionDescriptionProps) {
const { isPrivacyModeEnabled } = usePrivacyMode();
const fakeDescription = useMemo(
() => getFakeDescription(encryptedText),
[encryptedText],
);
const fakeDescription = useMemo(() => getFakeDescription(text), [text]);
if (!iv) {
if (isPrivacyModeEnabled) {
return <span className={className}>{fakeDescription}</span>;
}
return <span className={className}>{encryptedText}</span>;
}
if (isPrivacyModeEnabled && isKeySet) {
return <span className={className}>{fakeDescription}</span>;
if (encrypted) {
return <span className={className}>{ENCRYPTED_PLACEHOLDER}</span>;
}
return (
<EncryptedText
encryptedText={encryptedText}
iv={iv}
className={className}
length={length}
/>
<span className={className}>
{isPrivacyModeEnabled ? fakeDescription : text}
</span>
);
}

View File

@ -45,7 +45,6 @@ interface TransactionFiltersProps {
categories: Category[];
labels: Label[];
accounts: Account[];
isKeySet: boolean;
actions?: ReactNode;
hideAccountFilter?: boolean;
enableSavedFilters?: boolean;

View File

@ -64,11 +64,8 @@ import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
import { Skeleton } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { TableCell, TableRow } from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { consoleDebug } from '@/lib/debug';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { captureEvent } from '@/lib/posthog';
import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation';
import { transactionSyncService } from '@/services/transaction-sync';
@ -260,7 +257,6 @@ export function TransactionList({
hideColumns = [],
onBalanceUpdated,
}: TransactionListProps) {
const { isKeySet } = useEncryptionKey();
const locale = useLocale();
const [labels, setLabels] = useState<Label[]>(() => initialLabels ?? []);
@ -354,85 +350,26 @@ export function TransactionList({
useEffect(() => {
async function processTransactions() {
// If transactions are provided directly, use them as-is (already decrypted from backend)
// If transactions are provided directly, use them as-is.
if (providedTransactions) {
setIsLoading(true);
try {
const keyString = getStoredKey();
let key: CryptoKey | null = null;
if (keyString && isKeySet) {
try {
key = await importKey(keyString);
} catch (error) {
console.error(
'Failed to import encryption key:',
error,
);
}
}
const decrypted = await Promise.all(
providedTransactions.map(async (transaction) => {
try {
let decryptedDescription = '';
let decryptedNotes: string | null = null;
if (!transaction.description_iv) {
decryptedDescription =
transaction.description;
decryptedNotes = transaction.notes || null;
} else if (key) {
try {
decryptedDescription = await decrypt(
transaction.description,
key,
transaction.description_iv,
);
if (
transaction.notes &&
transaction.notes_iv
) {
decryptedNotes = await decrypt(
transaction.notes,
key,
transaction.notes_iv,
);
}
} catch (error) {
console.error(
'Failed to decrypt transaction:',
error,
);
}
}
return {
...transaction,
decryptedDescription,
decryptedNotes,
label_ids:
transaction.label_ids ??
transaction.labels?.map(
(label) => label.id,
) ??
[],
} as DecryptedTransaction;
} catch (error) {
console.error(
'Error processing transaction:',
error,
);
return null;
}
}),
const processed = providedTransactions.map(
(transaction) =>
({
...transaction,
decryptedDescription: transaction.description,
decryptedNotes: transaction.notes || null,
label_ids:
transaction.label_ids ??
transaction.labels?.map(
(label) => label.id,
) ??
[],
}) as DecryptedTransaction,
);
const validTransactions = decrypted.filter(
(t): t is DecryptedTransaction => t !== null,
);
setTransactions(validTransactions);
setTransactions(processed);
} catch (error) {
console.error('Error processing transactions:', error);
} finally {
@ -465,20 +402,6 @@ export function TransactionList({
);
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
const keyString = getStoredKey();
let key: CryptoKey | null = null;
if (keyString && isKeySet) {
try {
key = await importKey(keyString);
} catch (error) {
console.error(
'Failed to import encryption key:',
error,
);
}
}
const transformedTransactions = filteredServerData.map(
(serverRecord: Transaction) => {
const label_ids = serverRecord.labels?.map(
@ -498,75 +421,26 @@ export function TransactionList({
},
);
const decrypted = await Promise.all(
transformedTransactions.map(async (transaction) => {
try {
let decryptedDescription = '';
let decryptedNotes: string | null = null;
const processed = transformedTransactions.map((transaction) => {
const account = accountsMap.get(transaction.account_id);
const category = transaction.category_id
? categoriesMap.get(transaction.category_id)
: null;
const bank = account?.bank?.id
? banksMap.get(account.bank!.id)
: undefined;
if (!transaction.description_iv) {
decryptedDescription = transaction.description;
decryptedNotes = transaction.notes || null;
} else if (key) {
try {
decryptedDescription = await decrypt(
transaction.description,
key,
transaction.description_iv,
);
return {
...transaction,
decryptedDescription: transaction.description,
decryptedNotes: transaction.notes || null,
account,
category: category || null,
bank,
} as DecryptedTransaction;
});
if (
transaction.notes &&
transaction.notes_iv
) {
decryptedNotes = await decrypt(
transaction.notes,
key,
transaction.notes_iv,
);
}
} catch (error) {
console.error(
'Failed to decrypt transaction:',
transaction.id,
error,
);
}
}
const account = accountsMap.get(
transaction.account_id,
);
const category = transaction.category_id
? categoriesMap.get(transaction.category_id)
: null;
const bank = account?.bank?.id
? banksMap.get(account.bank!.id)
: undefined;
return {
...transaction,
decryptedDescription,
decryptedNotes,
account,
category: category || null,
bank,
} as DecryptedTransaction;
} catch (error) {
console.error(
'Failed to process transaction:',
transaction.id,
error,
);
return null;
}
}),
);
const validTransactions = decrypted.filter(
(transaction): transaction is DecryptedTransaction =>
transaction !== null,
);
const validTransactions = processed;
validTransactions.sort((a, b) => {
const dateA = parseISO(a.transaction_date).getTime();
@ -588,7 +462,6 @@ export function TransactionList({
accounts,
banks,
categories,
isKeySet,
accountId,
providedTransactions,
]);
@ -607,79 +480,6 @@ export function TransactionList({
}
}, [columnVisibility]);
useEffect(() => {
async function reDecryptTransactions() {
if (transactions.length === 0) {
return;
}
const keyString = getStoredKey();
let key: CryptoKey | null = null;
if (keyString && isKeySet) {
try {
key = await importKey(keyString);
} catch (error) {
console.error('Failed to import encryption key:', error);
}
}
const reDecrypted = await Promise.all(
transactions.map(async (transaction) => {
try {
let decryptedDescription = '';
let decryptedNotes: string | null = null;
if (!transaction.description_iv) {
decryptedDescription = transaction.description;
decryptedNotes = transaction.notes || null;
} else if (key) {
try {
decryptedDescription = await decrypt(
transaction.description,
key,
transaction.description_iv,
);
if (transaction.notes && transaction.notes_iv) {
decryptedNotes = await decrypt(
transaction.notes,
key,
transaction.notes_iv,
);
}
} catch (error) {
console.error(
'Failed to decrypt transaction:',
transaction.id,
error,
);
}
}
return {
...transaction,
decryptedDescription,
decryptedNotes,
} as DecryptedTransaction;
} catch (error) {
console.error(
'Failed to process transaction:',
transaction.id,
error,
);
return transaction;
}
}),
);
setTransactions(reDecrypted);
}
reDecryptTransactions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isKeySet]);
const [searchMatchedIds, setSearchMatchedIds] = useState<Set<string>>(
new Set(),
);
@ -687,7 +487,7 @@ export function TransactionList({
useEffect(() => {
async function searchInIndexedDB() {
if (!filters.searchText || !isKeySet) {
if (!filters.searchText) {
setSearchMatchedIds(new Set());
setIsSearching(false);
return;
@ -695,13 +495,6 @@ export function TransactionList({
setIsSearching(true);
try {
const keyString = getStoredKey();
if (!keyString) {
setSearchMatchedIds(new Set());
return;
}
const key = await importKey(keyString);
const searchLower = filters.searchText.toLowerCase();
let allIndexedTransactions = await db.transactions.toArray();
@ -715,46 +508,14 @@ export function TransactionList({
const matchedIds = new Set<string>();
for (const tx of allIndexedTransactions) {
try {
let decryptedDescription = '';
let decryptedNotes: string | null = null;
const matchesDescription = tx.description
.toLowerCase()
.includes(searchLower);
const matchesNotes =
tx.notes?.toLowerCase().includes(searchLower) || false;
try {
if (!tx.description_iv) {
decryptedDescription = tx.description;
decryptedNotes = tx.notes || null;
} else {
decryptedDescription = await decrypt(
tx.description,
key,
tx.description_iv,
);
if (tx.notes && tx.notes_iv) {
decryptedNotes = await decrypt(
tx.notes,
key,
tx.notes_iv,
);
}
}
} catch {
continue;
}
const matchesDescription = decryptedDescription
.toLowerCase()
.includes(searchLower);
const matchesNotes =
decryptedNotes
?.toLowerCase()
.includes(searchLower) || false;
if (matchesDescription || matchesNotes) {
matchedIds.add(tx.id);
}
} catch {
continue;
if (matchesDescription || matchesNotes) {
matchedIds.add(tx.id);
}
}
@ -768,7 +529,7 @@ export function TransactionList({
}
searchInIndexedDB();
}, [filters.searchText, isKeySet, accountId]);
}, [filters.searchText, accountId]);
const manualAccountIds = useMemo(
() =>
@ -782,11 +543,7 @@ export function TransactionList({
const filteredTransactions = useMemo(() => {
return transactions.filter((transaction) => {
if (filters.searchText && isKeySet) {
if (!searchMatchedIds.has(transaction.id)) {
return false;
}
} else if (filters.searchText && !isKeySet) {
if (filters.searchText && !searchMatchedIds.has(transaction.id)) {
return false;
}
@ -858,7 +615,7 @@ export function TransactionList({
return true;
});
}, [transactions, filters, isKeySet, accountId, searchMatchedIds]);
}, [transactions, filters, accountId, searchMatchedIds]);
const sortedTransactions = useMemo(() => {
if (sorting.length === 0) {
@ -1157,13 +914,6 @@ export function TransactionList({
}
async function handleBulkCategoryChange(categoryId: number | null) {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to update transactions',
);
return;
}
const selectedIds = Object.keys(rowSelection);
if (selectedIds.length === 0) {
return;
@ -1331,7 +1081,6 @@ export function TransactionList({
categories={categories}
labels={labels}
accounts={accounts}
isKeySet={isKeySet}
hideAccountFilter={hideAccountFilter}
actions={
<div className="flex justify-end gap-2">

View File

@ -1,4 +1,3 @@
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
@ -57,38 +56,16 @@ export function AmountDisplay({
monospace = false,
highlightPositive = false,
}: AmountDisplayProps) {
const { isKeySet } = useEncryptionKey();
const { isPrivacyModeEnabled } = usePrivacyMode();
const locale = useLocale();
const isPositive = amountInCents > 0;
const shouldHideAmount = !isKeySet;
const displayAmountInCents = useMemo(() => {
if (shouldHideAmount) {
const length = Math.max(3, amountInCents.toString().length);
return parseInt('8'.repeat(length - 2) + '00');
}
return amountInCents;
}, [amountInCents, shouldHideAmount]);
const formatted = useMemo(() => {
return formatCurrency(displayAmountInCents, currencyCode, locale, minimumFractionDigits, maximumFractionDigits);
}, [locale, displayAmountInCents, currencyCode, minimumFractionDigits, maximumFractionDigits]);
return formatCurrency(amountInCents, currencyCode, locale, minimumFractionDigits, maximumFractionDigits);
}, [locale, amountInCents, currencyCode, minimumFractionDigits, maximumFractionDigits]);
const getBackgroundClass = (shouldHideAmount: boolean) => {
if (!highlightPositive && !shouldHideAmount) return '';
if (shouldHideAmount) {
if (variant === 'positive-highlight' && isPositive) {
return 'rounded-xs bg-green-400 dark:bg-green-900 text-green-400 dark:text-green-900 opacity-20 dark:opacity-100';
}
return 'rounded-xs bg-zinc-950 dark:bg-zinc-700 dark:text-zinc-700';
}
if (variant === 'positive-highlight') {
const getBackgroundClass = () => {
if (highlightPositive && variant === 'positive-highlight') {
return 'bg-green-100/70 dark:bg-green-900';
}
@ -103,7 +80,7 @@ export function AmountDisplay({
variantStyles[variant],
size && sizeStyles[size],
weight && weightStyles[weight],
getBackgroundClass(shouldHideAmount),
getBackgroundClass(),
{ 'font-mono tabular-nums': monospace },
className,
)}

View File

@ -1,7 +1,4 @@
import type { AutomateCategorizationCandidate } from '@/components/automation-rules/automate-categorization-dialog';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { captureEvent } from '@/lib/posthog';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
@ -80,8 +77,6 @@ export function useCategorizeTransactions({
banks,
transactions: initialTransactions,
}: UseCategorizeTransactionsOptions) {
const { isKeySet } = useEncryptionKey();
const [uncategorizedTransactions, setUncategorizedTransactions] = useState<
DecryptedTransaction[]
>([]);
@ -97,7 +92,6 @@ export function useCategorizeTransactions({
const [automateDialogOpen, setAutomateDialogOpen] = useState(false);
const [automateCandidate, setAutomateCandidate] =
useState<AutomateCategorizationCandidate | null>(null);
const [, setEncryptionKey] = useState<CryptoKey | null>(null);
const [categorizedCount, setCategorizedCount] = useState(0);
const commandInputRef = useRef<HTMLInputElement>(null);
@ -112,115 +106,42 @@ export function useCategorizeTransactions({
}, [isLoading, animationState, currentIndex]);
useEffect(() => {
async function decryptTransactions() {
setIsLoading(true);
try {
const accountsMap = new Map(
accounts.map((account) => [account.id, account]),
);
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
setIsLoading(true);
try {
const accountsMap = new Map(
accounts.map((account) => [account.id, account]),
);
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
const keyString = getStoredKey();
let key: CryptoKey | null = null;
const processed = initialTransactions.map((transaction) => {
const account = accountsMap.get(transaction.account_id);
const bank = account?.bank?.id
? banksMap.get(account.bank.id)
: undefined;
if (keyString && isKeySet) {
try {
key = await importKey(keyString);
setEncryptionKey(key);
} catch (error) {
console.error(
'Failed to import encryption key:',
error,
);
}
}
return {
...transaction,
decryptedDescription: transaction.description,
decryptedNotes: transaction.notes || null,
account,
category: null,
bank,
} as DecryptedTransaction;
});
const decrypted = await Promise.all(
initialTransactions.map(async (transaction) => {
try {
let decryptedDescription = '';
let decryptedNotes: string | null = null;
processed.sort((a, b) => {
const dateA = parseISO(a.transaction_date).getTime();
const dateB = parseISO(b.transaction_date).getTime();
return dateB - dateA;
});
if (!transaction.description_iv) {
decryptedDescription = transaction.description;
decryptedNotes = transaction.notes || null;
} else if (key) {
try {
decryptedDescription = await decrypt(
transaction.description,
key,
transaction.description_iv,
);
if (
transaction.notes &&
transaction.notes_iv
) {
decryptedNotes = await decrypt(
transaction.notes,
key,
transaction.notes_iv,
);
}
} catch (error) {
console.error(
'Failed to decrypt transaction:',
transaction.id,
error,
);
}
}
const account = accountsMap.get(
transaction.account_id,
);
const bank = account?.bank?.id
? banksMap.get(account.bank.id)
: undefined;
return {
...transaction,
decryptedDescription,
decryptedNotes,
account,
category: null,
bank,
} as DecryptedTransaction;
} catch (error) {
console.error(
'Failed to process transaction:',
transaction.id,
error,
);
return null;
}
}),
);
const validTransactions = decrypted.filter(
(transaction): transaction is DecryptedTransaction =>
transaction !== null,
);
validTransactions.sort((a, b) => {
const dateA = parseISO(a.transaction_date).getTime();
const dateB = parseISO(b.transaction_date).getTime();
return dateB - dateA;
});
setUncategorizedTransactions(validTransactions);
} catch (error) {
console.error(
'Failed to load uncategorized transactions:',
error,
);
} finally {
setIsLoading(false);
}
setUncategorizedTransactions(processed);
} catch (error) {
console.error('Failed to load uncategorized transactions:', error);
} finally {
setIsLoading(false);
}
decryptTransactions();
}, [initialTransactions, accounts, banks, isKeySet]);
}, [initialTransactions, accounts, banks]);
const currentTransaction = uncategorizedTransactions[currentIndex];
const remainingCount = uncategorizedTransactions.length - currentIndex;

View File

@ -0,0 +1,90 @@
import { decrypt } from '@/lib/crypto';
import { renderHook, waitFor } from '@testing-library/react';
import axios from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useDecryptTransactions } from './use-decrypt-transactions';
vi.mock('axios');
vi.mock('@/contexts/encryption-key-context', () => ({
useEncryptionKey: () => ({ isKeySet: true }),
}));
vi.mock('@inertiajs/react', () => ({
usePage: () => ({ props: { hasEncryptedTransactions: true } }),
}));
vi.mock('@/lib/key-storage', () => ({
getStoredKey: () => 'stored-key',
}));
vi.mock('@/lib/crypto', () => ({
importKey: vi.fn(async () => 'crypto-key'),
decrypt: vi.fn(async (text: string) => `plain:${text}`),
}));
interface EncryptedRow {
id: string;
description: string;
description_iv: string | null;
notes: string | null;
notes_iv: string | null;
}
interface BulkBody {
transactions: { id: string }[];
}
const reloadMock = vi.fn();
function makeRow(id: string): EncryptedRow {
return {
id,
description: `cipher-${id}`,
description_iv: `iv-${id}`,
notes: null,
notes_iv: null,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(decrypt).mockImplementation(
async (text: string) => `plain:${text}`,
);
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: reloadMock },
});
});
describe('useDecryptTransactions', () => {
it('migrates every encrypted row even as the set shrinks under it', async () => {
// The server returns only the first still-encrypted rows on each
// fetch; each bulk update removes the patched rows. A cursor that
// advanced its offset would skip the rows shifting into freed slots.
let remaining = ['1', '2', '3', '4', '5'].map(makeRow);
vi.mocked(axios.get).mockImplementation(async () => ({
data: { data: remaining.slice(0, 2), next_page_url: null },
}));
vi.mocked(axios.patch).mockImplementation(async (_url, body) => {
const ids = (body as BulkBody).transactions.map((t) => t.id);
remaining = remaining.filter((row) => !ids.includes(row.id));
return { data: {} };
});
renderHook(() => useDecryptTransactions());
await waitFor(() => expect(reloadMock).toHaveBeenCalledTimes(1));
expect(remaining).toHaveLength(0);
});
it('stops instead of looping forever when rows never decrypt', async () => {
vi.mocked(decrypt).mockRejectedValue(new Error('wrong key'));
vi.mocked(axios.get).mockResolvedValue({
data: { data: [makeRow('1'), makeRow('2')], next_page_url: null },
});
renderHook(() => useDecryptTransactions());
await waitFor(() => expect(reloadMock).toHaveBeenCalledTimes(1));
expect(axios.patch).not.toHaveBeenCalled();
});
});

View File

@ -48,11 +48,20 @@ export function useDecryptTransactions() {
const key = await importKey(keyString);
let url: string | null = '/api/transactions?encrypted=true';
// Always re-fetch the first page: each bulk update clears the
// rows' IVs, removing them from the encrypted set, so an
// offset-based cursor (next_page_url) would skip the rows that
// shift into the freed slots. Stop when nothing is left, or
// when a page yields nothing we can decrypt — otherwise rows
// that always fail to decrypt would loop forever.
while (true) {
const { data: page } = await axios.get<PaginatedResponse>(
'/api/transactions?encrypted=true',
);
while (url) {
const { data: page } =
await axios.get<PaginatedResponse>(url);
if (page.data.length === 0) {
break;
}
const batch: BulkUpdateItem[] = [];
@ -86,17 +95,17 @@ export function useDecryptTransactions() {
}
}
if (batch.length > 0) {
// Send in chunks of 50
for (let i = 0; i < batch.length; i += 50) {
const chunk = batch.slice(i, i + 50);
await axios.patch('/api/transactions/bulk', {
transactions: chunk,
});
}
if (batch.length === 0) {
break;
}
url = page.next_page_url;
// Send in chunks of 50
for (let i = 0; i < batch.length; i += 50) {
const chunk = batch.slice(i, i + 50);
await axios.patch('/api/transactions/bulk', {
transactions: chunk,
});
}
}
window.location.reload();

View File

@ -37,31 +37,6 @@ export async function getAESKeyFromPBKDF(
);
}
export async function encrypt(
plaintext: string,
key: CryptoKey,
): Promise<{ encrypted: string; iv: string }> {
ensureCryptoAvailable();
const encoder = new TextEncoder();
const data = encoder.encode(plaintext);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encryptedBuffer = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv,
},
key,
data,
);
return {
encrypted: bufferToBase64(encryptedBuffer),
iv: bufferToBase64(iv),
};
}
export async function decrypt(
encrypted: string,
key: CryptoKey,
@ -84,11 +59,6 @@ export async function decrypt(
return decoder.decode(decryptedBuffer);
}
export function generateSalt(): Uint8Array {
ensureCryptoAvailable();
return window.crypto.getRandomValues(new Uint8Array(16));
}
export function bufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes =
buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);

View File

@ -29,9 +29,3 @@ export function clearKey(): void {
sessionStorage.removeItem(ENCRYPTION_KEY_NAME);
localStorage.removeItem(ENCRYPTION_KEY_NAME);
}
export function isKeyPersistent(): boolean {
if (!isBrowser()) return false;
return localStorage.getItem(ENCRYPTION_KEY_NAME) !== null;
}

View File

@ -24,10 +24,6 @@ vi.mock('@/actions/App/Http/Controllers/RealEstateDetailController', () => ({
},
}));
vi.mock('@/contexts/encryption-key-context', () => ({
useEncryptionKey: () => ({ isKeySet: true }),
}));
vi.mock('@/layouts/app/app-sidebar-layout', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
}));

View File

@ -40,7 +40,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { BreadcrumbItem } from '@/types';
@ -67,7 +66,6 @@ import { Head, router } from '@inertiajs/react';
import { ChevronDown, Pencil, Plus } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
import { toast } from 'sonner';
interface AccountWithDetails extends Account {
real_estate_detail?: RealEstateDetail;
@ -106,7 +104,6 @@ export default function AccountShow({
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
const [chartComputedData, setChartComputedData] =
useState<ChartComputedData | null>(null);
const { isKeySet } = useEncryptionKey();
const handleChartDataLoaded = useCallback((data: ChartComputedData) => {
setChartComputedData(data);
@ -117,13 +114,6 @@ export default function AccountShow({
}
function handleAddTransaction() {
if (!isKeySet) {
toast.error(
__('Please unlock your encryption key to add transactions'),
);
return;
}
setCreateTransactionOpen(true);
}

View File

@ -1,227 +0,0 @@
import { __ } from '@/utils/i18n';
import { Head, router } from '@inertiajs/react';
import axios from 'axios';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Spinner } from '@/components/ui/spinner';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import AuthLayout from '@/layouts/auth-layout';
import {
bufferToBase64,
encrypt,
exportKey,
generateSalt,
getAESKeyFromPBKDF,
getKeyFromPassword,
} from '@/lib/crypto';
import { storeKey } from '@/lib/key-storage';
import { dashboard } from '@/routes';
function isCryptoAvailable(): boolean {
if (typeof window === 'undefined') return true;
return !!(window.crypto && window.crypto.subtle);
}
function getInitialErrors(): {
password?: string;
confirmPassword?: string;
general?: string;
} {
if (typeof window === 'undefined') return {};
if (!window.crypto || !window.crypto.subtle) {
return {
general:
'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
};
}
return {};
}
export default function SetupEncryption() {
const { refreshKeyState } = useEncryptionKey();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [storagePreference, setStoragePreference] = useState<
'session' | 'persistent'
>('session');
const [processing, setProcessing] = useState(false);
const [cryptoAvailable] = useState(isCryptoAvailable);
const [errors, setErrors] = useState<{
password?: string;
confirmPassword?: string;
general?: string;
}>(getInitialErrors);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setErrors({});
if (password.length < 12) {
setErrors({
password: 'Encryption password must be at least 12 characters',
});
return;
}
if (password !== confirmPassword) {
setErrors({
confirmPassword: 'Passwords do not match',
});
return;
}
setProcessing(true);
try {
const salt = generateSalt();
const pbkdfKey = await getKeyFromPassword(password);
const aesKey = await getAESKeyFromPBKDF(pbkdfKey, salt);
const { encrypted, iv } = await encrypt('Hello, world', aesKey);
const exportedKey = await exportKey(aesKey);
await axios.post('/api/encryption/setup', {
salt: bufferToBase64(salt),
encrypted_content: encrypted,
iv: iv,
});
storeKey(exportedKey, storagePreference === 'persistent');
refreshKeyState();
router.visit(dashboard().url);
} catch (error) {
console.error('Encryption setup error:', error);
setErrors({
general:
'Failed to setup encryption. Please try again or contact support.',
});
setProcessing(false);
}
}
return (
<AuthLayout
title={__('Setup Encryption')}
description={__(
'Create a strong encryption password to secure your data',
)}
>
<Head title={__('Setup Encryption')} />
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="password">
{__('Encryption Password')}
</Label>
<Input
id="password"
type="password"
required
autoFocus
tabIndex={1}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={__(
'Enter a strong encryption password',
)}
disabled={processing}
/>
<p className="text-xs text-muted-foreground">
{__(
'Use a strong password (minimum 12 characters). This\n password will encrypt your data.',
)}
</p>
<InputError
message={errors.password}
className="mt-2"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">
{__('Confirm Password')}
</Label>
<Input
id="confirmPassword"
type="password"
required
tabIndex={2}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={__('Confirm your encryption password')}
disabled={processing}
/>
<InputError message={errors.confirmPassword} />
</div>
<div className="grid gap-2">
<Label htmlFor="storagePreference">
{__('Storage Preference')}
</Label>
<Select
value={storagePreference}
onValueChange={(value) =>
setStoragePreference(
value as 'session' | 'persistent',
)
}
disabled={processing}
>
<SelectTrigger id="storagePreference" tabIndex={3}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session">
{__('Session only (more secure)')}
</SelectItem>
<SelectItem value="persistent">
{__('Keep me logged in (less secure)')}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Session storage clears when you close your browser.
Persistent storage keeps your key until manually
cleared.
</p>
</div>
{errors.general && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{errors.general}
</div>
)}
<Button
type="submit"
className="mt-2 w-full"
tabIndex={4}
disabled={processing || !cryptoAvailable}
>
{processing && <Spinner />}
{__('Setup Encryption')}
</Button>
</div>
</form>
</AuthLayout>
);
}

View File

@ -1172,7 +1172,6 @@ export default function Transactions({
categories={categories}
labels={labels}
accounts={accounts}
isKeySet={true}
enableSavedFilters={true}
actions={
<div className="flex w-full items-center justify-between gap-2">

View File

@ -1,6 +1,4 @@
import { importKey } from '@/lib/crypto';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { TransactionSyncManager } from '@/lib/sync-manager';
import type { Transaction } from '@/types/transaction';
import type { UUID } from '@/types/uuid';
@ -233,42 +231,14 @@ class TransactionSyncService {
return txDate >= minDate && txDate <= maxDate;
});
const keyString = getStoredKey();
const key = keyString ? await importKey(keyString) : null;
const decryptedTransactions = await Promise.all(
transactionsInRange.map(async (t) => {
try {
let decryptedDescription: string;
if (t.description_iv && key) {
const { decrypt } = await import('@/lib/crypto');
decryptedDescription = await decrypt(
t.description,
key,
t.description_iv,
);
} else if (t.description_iv && !key) {
return null;
} else {
decryptedDescription = t.description;
}
return {
transaction_date: normalizeDate(t.transaction_date),
amount: parseFloat(t.amount),
description: decryptedDescription
.toLowerCase()
.trim()
.replace(/\s+/g, ' '),
};
} catch {
return null;
}
}),
);
const validDecryptedTransactions = decryptedTransactions.filter(
(t) => t !== null,
);
const existingTransactions = transactionsInRange.map((t) => ({
transaction_date: normalizeDate(t.transaction_date),
amount: parseFloat(t.amount),
description: t.description
.toLowerCase()
.trim()
.replace(/\s+/g, ' '),
}));
return transactions.map((importingTx) => {
const normalizedDescription = importingTx.description
@ -276,7 +246,7 @@ class TransactionSyncService {
.trim()
.replace(/\s+/g, ' ');
return validDecryptedTransactions.some(
return existingTransactions.some(
(existing) =>
existing.transaction_date ===
importingTx.transaction_date &&

View File

@ -14,8 +14,7 @@ use App\Http\Controllers\Sync\TransactionSyncController;
use Illuminate\Support\Facades\Route;
Route::middleware(['web', 'auth'])->group(function () {
// Encryption
Route::post('encryption/setup', [EncryptionController::class, 'setup']);
// Encryption (legacy decrypt-migration support only)
Route::get('encryption/message', [EncryptionController::class, 'getMessage']);
// Import Data (for import drawers)

View File

@ -0,0 +1,21 @@
<?php
use App\Models\Account;
function runAlignEncryptedFlagMigration(): void
{
$migration = require database_path('migrations/2026_06_20_105609_align_accounts_encrypted_flag_with_plaintext_names.php');
$migration->up();
}
it('clears the encrypted flag only for accounts whose name is plaintext', function () {
$staleFlag = Account::factory()->create(['encrypted' => true, 'name_iv' => null]);
$encryptedName = Account::factory()->create(['encrypted' => true, 'name_iv' => str_repeat('a', 16)]);
$alreadyPlaintext = Account::factory()->create(['encrypted' => false, 'name_iv' => null]);
runAlignEncryptedFlagMigration();
expect($staleFlag->fresh()->encrypted)->toBeFalse()
->and($encryptedName->fresh()->encrypted)->toBeTrue()
->and($alreadyPlaintext->fresh()->encrypted)->toBeFalse();
});

View File

@ -27,23 +27,6 @@ test('encrypted transactions endpoint returns only encrypted transactions', func
expect($data[0]['id'])->toBe($encrypted->id);
});
test('plaintext filter returns only plaintext transactions', function () {
Transaction::factory()->create([
'user_id' => $this->user->id,
'description_iv' => 'some-iv',
]);
$plaintext = Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
]);
$response = $this->getJson('/api/transactions?encrypted=false');
$response->assertOk();
$data = $response->json('data');
expect($data)->toHaveCount(1);
expect($data[0]['id'])->toBe($plaintext->id);
});
test('encrypted transactions endpoint paginates correctly', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
$category = Category::factory()->create(['user_id' => $this->user->id]);

View File

@ -4,7 +4,6 @@ use App\Models\EncryptedMessage;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
test('authenticated user without encryption salt can access setup page', function () {
$user = User::factory()->create(['encryption_salt' => null]);
@ -14,54 +13,6 @@ test('authenticated user without encryption salt can access setup page', functio
$response->assertSuccessful();
});
test('user can setup encryption', function () {
$user = User::factory()->create(['encryption_salt' => null]);
$response = actingAs($user)->postJson('/api/encryption/setup', [
'salt' => str_repeat('a', 24),
'encrypted_content' => 'encrypted_test_content',
'iv' => str_repeat('b', 16),
]);
$response->assertSuccessful();
$user->refresh();
expect($user->encryption_salt)->toBe(str_repeat('a', 24));
assertDatabaseHas('encrypted_messages', [
'user_id' => $user->id,
'encrypted_content' => 'encrypted_test_content',
'iv' => str_repeat('b', 16),
]);
});
test('encryption setup requires valid salt', function () {
$user = User::factory()->create(['encryption_salt' => null]);
$response = actingAs($user)->postJson('/api/encryption/setup', [
'salt' => 'invalid',
'encrypted_content' => 'encrypted_test_content',
'iv' => str_repeat('b', 16),
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['salt']);
});
test('encryption setup requires valid iv', function () {
$user = User::factory()->create(['encryption_salt' => null]);
$response = actingAs($user)->postJson('/api/encryption/setup', [
'salt' => str_repeat('a', 24),
'encrypted_content' => 'encrypted_test_content',
'iv' => 'invalid',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['iv']);
});
test('user can retrieve encrypted message', function () {
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),