diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index ffad4114..46a1f001 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -21,10 +21,10 @@ import {
ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_SCHEDULE, ROUTE_SCHEDULED_TIPS, ROUTE_FEED,
ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, ROUTE_ADDRESS_BOOK,
ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, ROUTE_REFUNDS,
- ROUTE_NOTIFICATION_PREFERENCES,
+ ROUTE_NOTIFICATION_PREFERENCES, ROUTE_ENCRYPTION,
DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META,
} from './config/routes';
-import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw, BellCog } from 'lucide-react';
+import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw, BellCog, Lock } from 'lucide-react';
import { activateDemo, deactivateDemo } from './lib/demo-utils';
import { useNotificationPreferences } from './context/NotificationPreferencesContext';
@@ -47,6 +47,7 @@ const AdminDashboard = lazy(() => import('./components/AdminDashboard'));
const TelemetryDashboard = lazy(() => import('./components/TelemetryDashboard'));
const RefundManager = lazy(() => import('./components/RefundManager'));
const NotificationPreferencesPage = lazy(() => import('./components/NotificationPreferences'));
+const EncryptionSettings = lazy(() => import('./components/EncryptionSettings'));
function App() {
const [userData, setUserData] = useState(null);
@@ -175,6 +176,7 @@ function App() {
{ path: ROUTE_BLOCK, label: 'Block', icon: ShieldBan },
{ path: ROUTE_REFUNDS, label: 'Refunds', icon: RotateCcw },
{ path: ROUTE_NOTIFICATION_PREFERENCES, label: 'Notifications', icon: BellCog },
+ { path: ROUTE_ENCRYPTION, label: 'Encryption', icon: Lock },
{ path: ROUTE_STATS, label: 'Stats', icon: BarChart3 },
];
@@ -421,6 +423,20 @@ function App() {
)
}
/>
+
+ {/* Encryption settings */}
+
+ ) : (
+
+
+
+ )
+ }
+ />
{/* Root and fallback */}
} />
diff --git a/frontend/src/components/EncryptionSettings.jsx b/frontend/src/components/EncryptionSettings.jsx
new file mode 100644
index 00000000..22007175
--- /dev/null
+++ b/frontend/src/components/EncryptionSettings.jsx
@@ -0,0 +1,151 @@
+import { useState } from 'react';
+import { useEncryption } from '../hooks/useEncryption';
+import { useSenderAddress } from '../hooks/useSenderAddress';
+import { Lock, RefreshCw, Copy, Check } from 'lucide-react';
+
+export default function EncryptionSettings({ addToast }) {
+ const senderAddress = useSenderAddress();
+ const { keys, loading, error, regenerateKeys } = useEncryption(senderAddress);
+ const [copied, setCopied] = useState(false);
+ const [regenerating, setRegenerating] = useState(false);
+
+ const handleCopyPublicKey = async () => {
+ if (!keys?.publicKey) return;
+
+ try {
+ await navigator.clipboard.writeText(keys.publicKey);
+ setCopied(true);
+ addToast('Public key copied to clipboard', 'success');
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ addToast('Failed to copy public key', 'error');
+ }
+ };
+
+ const handleRegenerateKeys = async () => {
+ if (!confirm('Are you sure you want to regenerate your encryption keys? You will not be able to decrypt old messages.')) {
+ return;
+ }
+
+ setRegenerating(true);
+ try {
+ await regenerateKeys();
+ addToast('Encryption keys regenerated successfully', 'success');
+ } catch (err) {
+ addToast('Failed to regenerate keys', 'error');
+ } finally {
+ setRegenerating(false);
+ }
+ };
+
+ if (!senderAddress) {
+ return (
+
+
+
+ Connect your wallet to manage encryption settings
+
+
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
Encryption Settings
+
Manage your message encryption keys
+
+
+
+
+
+
+ Your encryption keys are stored locally in your browser. They are used to encrypt and decrypt private tip messages.
+
+
+
+
+
+
+
+ {keys?.publicKey ? keys.publicKey.slice(0, 100) + '...' : 'No key available'}
+
+
+
+
+ Share this key with others so they can send you encrypted messages
+
+
+
+
+
+
+
+
+
+ {keys?.privateKey ? 'Private key is stored securely' : 'No private key available'}
+
+
+
+
+ Your private key never leaves your browser and is used to decrypt messages sent to you
+
+
+
+
+
+
+ Warning: Regenerating keys will prevent you from decrypting old messages
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx
index ecb28986..04dc922d 100644
--- a/frontend/src/components/SendTip.jsx
+++ b/frontend/src/components/SendTip.jsx
@@ -21,10 +21,12 @@ import { useStxPrice } from '../hooks/useStxPrice';
import { useSenderAddress } from '../hooks/useSenderAddress';
import { useContractFee } from '../hooks/useContractFee';
import { useTransactionFeeEstimate } from '../hooks/useTransactionFeeEstimate';
+import { useEncryption } from '../hooks/useEncryption';
import { analytics } from '../lib/analytics';
import ConfirmDialog from './ui/confirm-dialog';
import TxStatus from './ui/tx-status';
import AddressBook from './AddressBook';
+import { Lock, Unlock } from 'lucide-react';
const MIN_TIP_STX = 0.001; // minimum tip in STX
const MAX_TIP_STX = 10000; // maximum tip in STX
@@ -67,10 +69,12 @@ export default function SendTip({ addToast }) {
const [amountError, setAmountError] = useState('');
const [cooldown, setCooldown] = useState(0);
const [showAddressBook, setShowAddressBook] = useState(false);
+ const [encryptMessage, setEncryptMessage] = useState(false);
const cooldownRef = useRef(null);
const walletSenderAddress = useSenderAddress();
const senderAddress = demoEnabled ? getDemoData().mockWalletAddress : walletSenderAddress;
+ const { encrypt, canEncryptFor } = useEncryption(senderAddress);
const realBalanceState = useBalance(senderAddress);
const { displayBalance: balance, sendTipInDemo, pendingTransaction } = useSendTipWithDemo(realBalanceState.balance);
const balanceLoading = demoEnabled ? false : realBalanceState.loading;
@@ -93,6 +97,7 @@ export default function SendTip({ addToast }) {
} = useTransactionFeeEstimate();
const isRecipientHighRisk = !canProceedWithRecipient(recipient, blockedWarning);
+ const canEncrypt = recipient && canEncryptFor(recipient);
useEffect(() => {
return () => {
@@ -205,13 +210,25 @@ export default function SendTip({ addToast }) {
setLoading(true);
try {
+ let finalMessage = message || 'Thanks!';
+
+ if (encryptMessage && message && canEncrypt) {
+ try {
+ finalMessage = await encrypt(message, recipient.trim());
+ } catch (encryptError) {
+ addToast('Failed to encrypt message. Sending unencrypted.', 'warning');
+ finalMessage = message;
+ }
+ }
+
if (demoEnabled) {
- const result = await sendTipInDemo(recipient.trim(), amount, message, category);
+ const result = await sendTipInDemo(recipient.trim(), amount, finalMessage, category);
setPendingTx(result);
setRecipient('');
setAmount('');
setMessage('');
setCategory(0);
+ setEncryptMessage(false);
startCooldown();
addToast('Demo tip sent successfully.', 'success');
setLoading(false);
@@ -226,7 +243,7 @@ export default function SendTip({ addToast }) {
const functionArgs = [
principalCV(recipient.trim()),
uintCV(microSTX),
- stringUtf8CV(message || 'Thanks!'),
+ stringUtf8CV(finalMessage),
uintCV(category)
];
@@ -246,6 +263,7 @@ export default function SendTip({ addToast }) {
setAmount('');
setMessage('');
setCategory(0);
+ setEncryptMessage(false);
notifyTipSent();
startCooldown();
analytics.trackTipConfirmed();
@@ -262,7 +280,6 @@ export default function SendTip({ addToast }) {
console.error('Failed to send tip:', msg);
analytics.trackTipFailed();
- // Provide a more specific message for post-condition failures
if (msg.toLowerCase().includes('post-condition') || msg.toLowerCase().includes('postcondition')) {
addToast('Transaction rejected by post-condition check. Your funds are safe.', 'error');
} else {
@@ -360,13 +377,46 @@ export default function SendTip({ addToast }) {
{/* Message */}
-
+
+
+ {message && (
+
+ )}
+
{/* Category */}
@@ -489,7 +539,17 @@ export default function SendTip({ addToast }) {
Send {amount} STX to:
{recipient}
Category: {TIP_CATEGORIES.find(c => c.id === category)?.label}
- {message && "{message}"
}
+ {message && (
+
+
"{message}"
+ {encryptMessage && canEncrypt && (
+
+
+ Message will be encrypted
+
+ )}
+
+ )}
{amount && parseFloat(amount) > 0 && (
diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx
index 38b3833c..7747fe5e 100644
--- a/frontend/src/components/TipHistory.jsx
+++ b/frontend/src/components/TipHistory.jsx
@@ -10,7 +10,8 @@ import { useDemoMode } from '../context/DemoContext';
import RefundRequest from './RefundRequest';
import RefundApproval from './RefundApproval';
import TipHistoryExport from './TipHistoryExport';
-import { Download } from 'lucide-react';
+import { Download, Lock, Eye, EyeOff } from 'lucide-react';
+import { useEncryption } from '../hooks/useEncryption';
const CATEGORY_LABELS = {
0: 'General', 1: 'Content Creation', 2: 'Open Source',
@@ -52,7 +53,10 @@ export default function TipHistory({ userAddress, addToast }) {
const noop = () => {};
const toast = addToast || noop;
const { demoEnabled, getDemoData, demoTips: contextDemoTips } = useDemoMode();
+ const { decrypt, isEncrypted } = useEncryption(userAddress);
const [tips, setTips] = useState([]);
+ const [decryptedMessages, setDecryptedMessages] = useState({});
+ const [revealedMessages, setRevealedMessages] = useState({});
const [tipsLoading, setTipsLoading] = useState(true);
const [tipsError, setTipsError] = useState(null);
const [tipsMeta, setTipsMeta] = useState({ offset: 0, total: 0, hasMore: false });
@@ -64,6 +68,33 @@ export default function TipHistory({ userAddress, addToast }) {
const [loadingMore, setLoadingMore] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
+ const handleDecryptMessage = useCallback(async (tipId, message) => {
+ if (!isEncrypted(message)) return;
+
+ try {
+ const decrypted = await decrypt(message);
+ setDecryptedMessages(prev => ({ ...prev, [tipId]: decrypted }));
+ } catch (err) {
+ console.error('Failed to decrypt message:', err);
+ setDecryptedMessages(prev => ({ ...prev, [tipId]: '[Decryption failed]' }));
+ }
+ }, [decrypt, isEncrypted]);
+
+ const toggleRevealMessage = useCallback((tipId, message) => {
+ if (revealedMessages[tipId]) {
+ setRevealedMessages(prev => {
+ const next = { ...prev };
+ delete next[tipId];
+ return next;
+ });
+ } else {
+ setRevealedMessages(prev => ({ ...prev, [tipId]: true }));
+ if (isEncrypted(message) && !decryptedMessages[tipId]) {
+ handleDecryptMessage(tipId, message);
+ }
+ }
+ }, [revealedMessages, isEncrypted, decryptedMessages, handleDecryptMessage]);
+
const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`;
const demoWalletAddress = demoEnabled ? getDemoData().mockWalletAddress : null;
@@ -327,7 +358,35 @@ export default function TipHistory({ userAddress, addToast }) {
{tip.message ? (
-
“{tip.message}”
+
+ {isEncrypted(tip.message) && (
+
+ )}
+ {isEncrypted(tip.message) && !revealedMessages[tip.tipId] ? (
+
+ ) : isEncrypted(tip.message) && revealedMessages[tip.tipId] ? (
+
+
+ “{decryptedMessages[tip.tipId] || 'Decrypting...'}”
+
+
+
+ ) : (
+
“{tip.message}”
+ )}
+
) : null}
diff --git a/frontend/src/config/routes.js b/frontend/src/config/routes.js
index b30a6d6b..24abad95 100644
--- a/frontend/src/config/routes.js
+++ b/frontend/src/config/routes.js
@@ -115,6 +115,12 @@ export const ROUTE_REFUNDS = '/refunds';
*/
export const ROUTE_NOTIFICATION_PREFERENCES = '/notification-preferences';
+/**
+ * Encryption settings for managing message encryption keys.
+ * @type {string}
+ */
+export const ROUTE_ENCRYPTION = '/encryption';
+
/**
* The route that "/" redirects to when the user is authenticated.
* Change this single value to alter the default landing page site-wide.
@@ -144,6 +150,7 @@ export const ROUTE_LABELS = {
[ROUTE_TELEMETRY]: 'Telemetry',
[ROUTE_REFUNDS]: 'Refunds',
[ROUTE_NOTIFICATION_PREFERENCES]: 'Notifications',
+ [ROUTE_ENCRYPTION]: 'Encryption',
};
/**
@@ -171,6 +178,7 @@ export const ROUTE_TITLES = {
[ROUTE_TELEMETRY]: 'Telemetry -- TipStream',
[ROUTE_REFUNDS]: 'Refunds -- TipStream',
[ROUTE_NOTIFICATION_PREFERENCES]: 'Notification Preferences -- TipStream',
+ [ROUTE_ENCRYPTION]: 'Encryption Settings -- TipStream',
};
/**
@@ -270,4 +278,9 @@ export const ROUTE_META = {
requiresAuth: true,
adminOnly: false,
},
+ [ROUTE_ENCRYPTION]: {
+ description: 'Manage encryption keys for private tip messages.',
+ requiresAuth: true,
+ adminOnly: false,
+ },
};
diff --git a/frontend/src/hooks/useEncryption.js b/frontend/src/hooks/useEncryption.js
new file mode 100644
index 00000000..ed268515
--- /dev/null
+++ b/frontend/src/hooks/useEncryption.js
@@ -0,0 +1,100 @@
+import { useState, useEffect, useCallback } from 'react';
+import {
+ ensureUserKeys,
+ loadKeysFromStorage,
+ deleteKeysFromStorage,
+ encryptMessage,
+ decryptMessage,
+ isEncryptedMessage,
+} from '../lib/encryption';
+import {
+ getPublicKeyFromRegistry,
+ savePublicKeyToRegistry,
+} from '../lib/publicKeyRegistry';
+
+export function useEncryption(userAddress) {
+ const [keys, setKeys] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!userAddress) {
+ setKeys(null);
+ setLoading(false);
+ return;
+ }
+
+ const initKeys = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const userKeys = await ensureUserKeys(userAddress);
+ setKeys(userKeys);
+
+ savePublicKeyToRegistry(userAddress, userKeys.publicKey);
+ } catch (err) {
+ setError(err.message);
+ setKeys(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ initKeys();
+ }, [userAddress]);
+
+ const encrypt = useCallback(async (message, recipientAddress) => {
+ if (!message) return message;
+
+ const recipientPublicKey = getPublicKeyFromRegistry(recipientAddress);
+ if (!recipientPublicKey) {
+ throw new Error('Recipient public key not found');
+ }
+
+ return await encryptMessage(message, recipientPublicKey);
+ }, []);
+
+ const decrypt = useCallback(async (encryptedMessage) => {
+ if (!encryptedMessage || !isEncryptedMessage(encryptedMessage)) {
+ return encryptedMessage;
+ }
+
+ if (!keys?.privateKey) {
+ throw new Error('Private key not available');
+ }
+
+ return await decryptMessage(encryptedMessage, keys.privateKey);
+ }, [keys]);
+
+ const regenerateKeys = useCallback(async () => {
+ if (!userAddress) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+ deleteKeysFromStorage(userAddress);
+ const newKeys = await ensureUserKeys(userAddress);
+ setKeys(newKeys);
+ savePublicKeyToRegistry(userAddress, newKeys.publicKey);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [userAddress]);
+
+ const canEncryptFor = useCallback((recipientAddress) => {
+ return !!getPublicKeyFromRegistry(recipientAddress);
+ }, []);
+
+ return {
+ keys,
+ loading,
+ error,
+ encrypt,
+ decrypt,
+ regenerateKeys,
+ canEncryptFor,
+ isEncrypted: isEncryptedMessage,
+ };
+}
diff --git a/frontend/src/lib/encryption.js b/frontend/src/lib/encryption.js
new file mode 100644
index 00000000..0c965a8c
--- /dev/null
+++ b/frontend/src/lib/encryption.js
@@ -0,0 +1,177 @@
+const ENCRYPTION_PREFIX = 'ENC:';
+const ALGORITHM = 'RSA-OAEP';
+const HASH = 'SHA-256';
+const KEY_SIZE = 2048;
+
+export function isEncryptedMessage(message) {
+ return typeof message === 'string' && message.startsWith(ENCRYPTION_PREFIX);
+}
+
+export function stripEncryptionPrefix(message) {
+ if (isEncryptedMessage(message)) {
+ return message.slice(ENCRYPTION_PREFIX.length);
+ }
+ return message;
+}
+
+export async function generateKeyPair() {
+ const keyPair = await crypto.subtle.generateKey(
+ {
+ name: ALGORITHM,
+ modulusLength: KEY_SIZE,
+ publicExponent: new Uint8Array([1, 0, 1]),
+ hash: HASH,
+ },
+ true,
+ ['encrypt', 'decrypt']
+ );
+ return keyPair;
+}
+
+export async function exportPublicKey(publicKey) {
+ const exported = await crypto.subtle.exportKey('spki', publicKey);
+ const exportedAsBase64 = btoa(String.fromCharCode(...new Uint8Array(exported)));
+ return exportedAsBase64;
+}
+
+export async function exportPrivateKey(privateKey) {
+ const exported = await crypto.subtle.exportKey('pkcs8', privateKey);
+ const exportedAsBase64 = btoa(String.fromCharCode(...new Uint8Array(exported)));
+ return exportedAsBase64;
+}
+
+export async function importPublicKey(base64Key) {
+ const binaryString = atob(base64Key);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ const publicKey = await crypto.subtle.importKey(
+ 'spki',
+ bytes,
+ {
+ name: ALGORITHM,
+ hash: HASH,
+ },
+ true,
+ ['encrypt']
+ );
+ return publicKey;
+}
+
+export async function importPrivateKey(base64Key) {
+ const binaryString = atob(base64Key);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ const privateKey = await crypto.subtle.importKey(
+ 'pkcs8',
+ bytes,
+ {
+ name: ALGORITHM,
+ hash: HASH,
+ },
+ true,
+ ['decrypt']
+ );
+ return privateKey;
+}
+
+export async function encryptMessage(message, publicKeyBase64) {
+ if (!message || !publicKeyBase64) {
+ throw new Error('Message and public key are required');
+ }
+
+ const publicKey = await importPublicKey(publicKeyBase64);
+ const encoder = new TextEncoder();
+ const data = encoder.encode(message);
+
+ const encrypted = await crypto.subtle.encrypt(
+ {
+ name: ALGORITHM,
+ },
+ publicKey,
+ data
+ );
+
+ const encryptedBase64 = btoa(String.fromCharCode(...new Uint8Array(encrypted)));
+ return ENCRYPTION_PREFIX + encryptedBase64;
+}
+
+export async function decryptMessage(encryptedMessage, privateKeyBase64) {
+ if (!encryptedMessage || !privateKeyBase64) {
+ throw new Error('Encrypted message and private key are required');
+ }
+
+ if (!isEncryptedMessage(encryptedMessage)) {
+ return encryptedMessage;
+ }
+
+ const encryptedData = stripEncryptionPrefix(encryptedMessage);
+ const privateKey = await importPrivateKey(privateKeyBase64);
+
+ const binaryString = atob(encryptedData);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ name: ALGORITHM,
+ },
+ privateKey,
+ bytes
+ );
+
+ const decoder = new TextDecoder();
+ return decoder.decode(decrypted);
+}
+
+export function getStorageKey(address) {
+ return `tipstream_keys_${address}`;
+}
+
+export function saveKeysToStorage(address, publicKey, privateKey) {
+ const storageKey = getStorageKey(address);
+ const keys = {
+ publicKey,
+ privateKey,
+ createdAt: Date.now(),
+ };
+ localStorage.setItem(storageKey, JSON.stringify(keys));
+}
+
+export function loadKeysFromStorage(address) {
+ const storageKey = getStorageKey(address);
+ const stored = localStorage.getItem(storageKey);
+ if (!stored) return null;
+
+ try {
+ return JSON.parse(stored);
+ } catch {
+ return null;
+ }
+}
+
+export function deleteKeysFromStorage(address) {
+ const storageKey = getStorageKey(address);
+ localStorage.removeItem(storageKey);
+}
+
+export async function ensureUserKeys(address) {
+ let keys = loadKeysFromStorage(address);
+
+ if (!keys) {
+ const keyPair = await generateKeyPair();
+ const publicKey = await exportPublicKey(keyPair.publicKey);
+ const privateKey = await exportPrivateKey(keyPair.privateKey);
+ saveKeysToStorage(address, publicKey, privateKey);
+ keys = { publicKey, privateKey };
+ }
+
+ return keys;
+}
diff --git a/frontend/src/lib/publicKeyRegistry.js b/frontend/src/lib/publicKeyRegistry.js
new file mode 100644
index 00000000..275967dc
--- /dev/null
+++ b/frontend/src/lib/publicKeyRegistry.js
@@ -0,0 +1,61 @@
+const REGISTRY_KEY_PREFIX = 'tipstream_public_key_';
+
+export function getPublicKeyStorageKey(address) {
+ return `${REGISTRY_KEY_PREFIX}${address}`;
+}
+
+export function savePublicKeyToRegistry(address, publicKey) {
+ const key = getPublicKeyStorageKey(address);
+ const data = {
+ publicKey,
+ address,
+ timestamp: Date.now(),
+ };
+ localStorage.setItem(key, JSON.stringify(data));
+}
+
+export function getPublicKeyFromRegistry(address) {
+ const key = getPublicKeyStorageKey(address);
+ const stored = localStorage.getItem(key);
+
+ if (!stored) return null;
+
+ try {
+ const data = JSON.parse(stored);
+ return data.publicKey;
+ } catch {
+ return null;
+ }
+}
+
+export function removePublicKeyFromRegistry(address) {
+ const key = getPublicKeyStorageKey(address);
+ localStorage.removeItem(key);
+}
+
+export function getAllPublicKeys() {
+ const keys = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(REGISTRY_KEY_PREFIX)) {
+ try {
+ const data = JSON.parse(localStorage.getItem(key));
+ keys.push(data);
+ } catch {
+ continue;
+ }
+ }
+ }
+ return keys;
+}
+
+export function clearPublicKeyRegistry() {
+ const keysToRemove = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(REGISTRY_KEY_PREFIX)) {
+ keysToRemove.push(key);
+ }
+ }
+ keysToRemove.forEach(key => localStorage.removeItem(key));
+}
diff --git a/frontend/src/test/encryption.test.js b/frontend/src/test/encryption.test.js
new file mode 100644
index 00000000..829d68ab
--- /dev/null
+++ b/frontend/src/test/encryption.test.js
@@ -0,0 +1,268 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import {
+ generateKeyPair,
+ exportPublicKey,
+ exportPrivateKey,
+ importPublicKey,
+ importPrivateKey,
+ encryptMessage,
+ decryptMessage,
+ isEncryptedMessage,
+ stripEncryptionPrefix,
+ saveKeysToStorage,
+ loadKeysFromStorage,
+ deleteKeysFromStorage,
+ ensureUserKeys,
+} from '../lib/encryption';
+
+describe('encryption', () => {
+ const testAddress = 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7';
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ describe('key generation', () => {
+ it('should generate a key pair', async () => {
+ const keyPair = await generateKeyPair();
+ expect(keyPair).toBeDefined();
+ expect(keyPair.publicKey).toBeDefined();
+ expect(keyPair.privateKey).toBeDefined();
+ });
+
+ it('should export public key as base64', async () => {
+ const keyPair = await generateKeyPair();
+ const exported = await exportPublicKey(keyPair.publicKey);
+ expect(typeof exported).toBe('string');
+ expect(exported.length).toBeGreaterThan(0);
+ });
+
+ it('should export private key as base64', async () => {
+ const keyPair = await generateKeyPair();
+ const exported = await exportPrivateKey(keyPair.privateKey);
+ expect(typeof exported).toBe('string');
+ expect(exported.length).toBeGreaterThan(0);
+ });
+
+ it('should import public key from base64', async () => {
+ const keyPair = await generateKeyPair();
+ const exported = await exportPublicKey(keyPair.publicKey);
+ const imported = await importPublicKey(exported);
+ expect(imported).toBeDefined();
+ });
+
+ it('should import private key from base64', async () => {
+ const keyPair = await generateKeyPair();
+ const exported = await exportPrivateKey(keyPair.privateKey);
+ const imported = await importPrivateKey(exported);
+ expect(imported).toBeDefined();
+ });
+ });
+
+ describe('encryption and decryption', () => {
+ it('should encrypt a message', async () => {
+ const keyPair = await generateKeyPair();
+ const publicKey = await exportPublicKey(keyPair.publicKey);
+ const message = 'Hello, World!';
+
+ const encrypted = await encryptMessage(message, publicKey);
+ expect(encrypted).toBeDefined();
+ expect(encrypted).not.toBe(message);
+ expect(encrypted.startsWith('ENC:')).toBe(true);
+ });
+
+ it('should decrypt an encrypted message', async () => {
+ const keyPair = await generateKeyPair();
+ const publicKey = await exportPublicKey(keyPair.publicKey);
+ const privateKey = await exportPrivateKey(keyPair.privateKey);
+ const message = 'Hello, World!';
+
+ const encrypted = await encryptMessage(message, publicKey);
+ const decrypted = await decryptMessage(encrypted, privateKey);
+
+ expect(decrypted).toBe(message);
+ }, 10000);
+
+ it('should handle special characters', async () => {
+ const keyPair = await generateKeyPair();
+ const publicKey = await exportPublicKey(keyPair.publicKey);
+ const privateKey = await exportPrivateKey(keyPair.privateKey);
+ const message = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?';
+
+ const encrypted = await encryptMessage(message, publicKey);
+ const decrypted = await decryptMessage(encrypted, privateKey);
+
+ expect(decrypted).toBe(message);
+ });
+
+ it('should handle unicode characters', async () => {
+ const keyPair = await generateKeyPair();
+ const publicKey = await exportPublicKey(keyPair.publicKey);
+ const privateKey = await exportPrivateKey(keyPair.privateKey);
+ const message = 'Unicode: 你好世界 🌍 مرحبا';
+
+ const encrypted = await encryptMessage(message, publicKey);
+ const decrypted = await decryptMessage(encrypted, privateKey);
+
+ expect(decrypted).toBe(message);
+ });
+
+ it('should handle long messages', async () => {
+ const keyPair = await generateKeyPair();
+ const publicKey = await exportPublicKey(keyPair.publicKey);
+ const privateKey = await exportPrivateKey(keyPair.privateKey);
+ const message = 'A'.repeat(100);
+
+ const encrypted = await encryptMessage(message, publicKey);
+ const decrypted = await decryptMessage(encrypted, privateKey);
+
+ expect(decrypted).toBe(message);
+ });
+
+ it('should throw error when encrypting without public key', async () => {
+ await expect(encryptMessage('test', '')).rejects.toThrow();
+ });
+
+ it('should throw error when decrypting without private key', async () => {
+ await expect(decryptMessage('ENC:test', '')).rejects.toThrow();
+ });
+
+ it('should return unencrypted message if not encrypted', async () => {
+ const keyPair = await generateKeyPair();
+ const privateKey = await exportPrivateKey(keyPair.privateKey);
+ const message = 'Plain text';
+
+ const result = await decryptMessage(message, privateKey);
+ expect(result).toBe(message);
+ });
+ });
+
+ describe('encryption detection', () => {
+ it('should detect encrypted messages', () => {
+ expect(isEncryptedMessage('ENC:abc123')).toBe(true);
+ });
+
+ it('should not detect unencrypted messages', () => {
+ expect(isEncryptedMessage('Hello')).toBe(false);
+ expect(isEncryptedMessage('')).toBe(false);
+ expect(isEncryptedMessage(null)).toBe(false);
+ });
+
+ it('should strip encryption prefix', () => {
+ expect(stripEncryptionPrefix('ENC:abc123')).toBe('abc123');
+ expect(stripEncryptionPrefix('Hello')).toBe('Hello');
+ });
+ });
+
+ describe('key storage', () => {
+ it('should save keys to localStorage', () => {
+ const publicKey = 'public-key-data';
+ const privateKey = 'private-key-data';
+
+ saveKeysToStorage(testAddress, publicKey, privateKey);
+
+ const stored = localStorage.getItem(`tipstream_keys_${testAddress}`);
+ expect(stored).toBeDefined();
+
+ const parsed = JSON.parse(stored);
+ expect(parsed.publicKey).toBe(publicKey);
+ expect(parsed.privateKey).toBe(privateKey);
+ expect(parsed.createdAt).toBeDefined();
+ });
+
+ it('should load keys from localStorage', () => {
+ const publicKey = 'public-key-data';
+ const privateKey = 'private-key-data';
+
+ saveKeysToStorage(testAddress, publicKey, privateKey);
+ const loaded = loadKeysFromStorage(testAddress);
+
+ expect(loaded).toBeDefined();
+ expect(loaded.publicKey).toBe(publicKey);
+ expect(loaded.privateKey).toBe(privateKey);
+ });
+
+ it('should return null when no keys exist', () => {
+ const loaded = loadKeysFromStorage(testAddress);
+ expect(loaded).toBeNull();
+ });
+
+ it('should delete keys from localStorage', () => {
+ const publicKey = 'public-key-data';
+ const privateKey = 'private-key-data';
+
+ saveKeysToStorage(testAddress, publicKey, privateKey);
+ deleteKeysFromStorage(testAddress);
+
+ const loaded = loadKeysFromStorage(testAddress);
+ expect(loaded).toBeNull();
+ });
+
+ it('should handle corrupted storage data', () => {
+ localStorage.setItem(`tipstream_keys_${testAddress}`, 'invalid-json');
+ const loaded = loadKeysFromStorage(testAddress);
+ expect(loaded).toBeNull();
+ });
+ });
+
+ describe('ensureUserKeys', () => {
+ it('should generate keys if none exist', async () => {
+ const keys = await ensureUserKeys(testAddress);
+
+ expect(keys).toBeDefined();
+ expect(keys.publicKey).toBeDefined();
+ expect(keys.privateKey).toBeDefined();
+
+ const stored = loadKeysFromStorage(testAddress);
+ expect(stored).toBeDefined();
+ expect(stored.publicKey).toBe(keys.publicKey);
+ });
+
+ it('should return existing keys if they exist', async () => {
+ const firstKeys = await ensureUserKeys(testAddress);
+ const secondKeys = await ensureUserKeys(testAddress);
+
+ expect(secondKeys.publicKey).toBe(firstKeys.publicKey);
+ expect(secondKeys.privateKey).toBe(firstKeys.privateKey);
+ });
+
+ it('should save keys to storage when generating', async () => {
+ await ensureUserKeys(testAddress);
+
+ const stored = loadKeysFromStorage(testAddress);
+ expect(stored).toBeDefined();
+ expect(stored.publicKey).toBeDefined();
+ expect(stored.privateKey).toBeDefined();
+ });
+ });
+
+ describe('end-to-end encryption flow', () => {
+ it('should complete full encryption flow', async () => {
+ const senderKeys = await ensureUserKeys('sender-address');
+ const recipientKeys = await ensureUserKeys('recipient-address');
+
+ const message = 'Secret message';
+ const encrypted = await encryptMessage(message, recipientKeys.publicKey);
+ const decrypted = await decryptMessage(encrypted, recipientKeys.privateKey);
+
+ expect(decrypted).toBe(message);
+ }, 15000);
+
+ it('should fail to decrypt with wrong key', async () => {
+ const senderKeys = await ensureUserKeys('sender-address');
+ const recipientKeys = await ensureUserKeys('recipient-address');
+ const wrongKeys = await ensureUserKeys('wrong-address');
+
+ const message = 'Secret message';
+ const encrypted = await encryptMessage(message, recipientKeys.publicKey);
+
+ await expect(
+ decryptMessage(encrypted, wrongKeys.privateKey)
+ ).rejects.toThrow();
+ }, 20000);
+ });
+});
diff --git a/frontend/src/test/publicKeyRegistry.test.js b/frontend/src/test/publicKeyRegistry.test.js
new file mode 100644
index 00000000..684fbdae
--- /dev/null
+++ b/frontend/src/test/publicKeyRegistry.test.js
@@ -0,0 +1,208 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+ savePublicKeyToRegistry,
+ getPublicKeyFromRegistry,
+ removePublicKeyFromRegistry,
+ getAllPublicKeys,
+ clearPublicKeyRegistry,
+} from '../lib/publicKeyRegistry';
+
+describe('publicKeyRegistry', () => {
+ const testAddress1 = 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7';
+ const testAddress2 = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE';
+ const testPublicKey1 = 'public-key-data-1';
+ const testPublicKey2 = 'public-key-data-2';
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ describe('savePublicKeyToRegistry', () => {
+ it('should save public key to registry', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+
+ const key = `tipstream_public_key_${testAddress1}`;
+ const stored = localStorage.getItem(key);
+ expect(stored).toBeDefined();
+
+ const parsed = JSON.parse(stored);
+ expect(parsed.publicKey).toBe(testPublicKey1);
+ expect(parsed.address).toBe(testAddress1);
+ expect(parsed.timestamp).toBeDefined();
+ });
+
+ it('should overwrite existing key', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ savePublicKeyToRegistry(testAddress1, testPublicKey2);
+
+ const retrieved = getPublicKeyFromRegistry(testAddress1);
+ expect(retrieved).toBe(testPublicKey2);
+ });
+
+ it('should save multiple addresses', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ savePublicKeyToRegistry(testAddress2, testPublicKey2);
+
+ expect(getPublicKeyFromRegistry(testAddress1)).toBe(testPublicKey1);
+ expect(getPublicKeyFromRegistry(testAddress2)).toBe(testPublicKey2);
+ });
+ });
+
+ describe('getPublicKeyFromRegistry', () => {
+ it('should retrieve saved public key', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ const retrieved = getPublicKeyFromRegistry(testAddress1);
+ expect(retrieved).toBe(testPublicKey1);
+ });
+
+ it('should return null for non-existent address', () => {
+ const retrieved = getPublicKeyFromRegistry(testAddress1);
+ expect(retrieved).toBeNull();
+ });
+
+ it('should handle corrupted data', () => {
+ const key = `tipstream_public_key_${testAddress1}`;
+ localStorage.setItem(key, 'invalid-json');
+
+ const retrieved = getPublicKeyFromRegistry(testAddress1);
+ expect(retrieved).toBeNull();
+ });
+ });
+
+ describe('removePublicKeyFromRegistry', () => {
+ it('should remove public key from registry', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ removePublicKeyFromRegistry(testAddress1);
+
+ const retrieved = getPublicKeyFromRegistry(testAddress1);
+ expect(retrieved).toBeNull();
+ });
+
+ it('should not affect other keys', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ savePublicKeyToRegistry(testAddress2, testPublicKey2);
+
+ removePublicKeyFromRegistry(testAddress1);
+
+ expect(getPublicKeyFromRegistry(testAddress1)).toBeNull();
+ expect(getPublicKeyFromRegistry(testAddress2)).toBe(testPublicKey2);
+ });
+
+ it('should handle removing non-existent key', () => {
+ expect(() => {
+ removePublicKeyFromRegistry(testAddress1);
+ }).not.toThrow();
+ });
+ });
+
+ describe('getAllPublicKeys', () => {
+ it('should return empty array when no keys exist', () => {
+ const keys = getAllPublicKeys();
+ expect(keys).toEqual([]);
+ });
+
+ it('should return all saved public keys', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ savePublicKeyToRegistry(testAddress2, testPublicKey2);
+
+ const keys = getAllPublicKeys();
+ expect(keys.length).toBe(2);
+
+ const addresses = keys.map(k => k.address);
+ expect(addresses).toContain(testAddress1);
+ expect(addresses).toContain(testAddress2);
+ });
+
+ it('should include all key data', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+
+ const keys = getAllPublicKeys();
+ expect(keys[0].publicKey).toBe(testPublicKey1);
+ expect(keys[0].address).toBe(testAddress1);
+ expect(keys[0].timestamp).toBeDefined();
+ });
+
+ it('should skip corrupted entries', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ localStorage.setItem('tipstream_public_key_corrupted', 'invalid-json');
+ savePublicKeyToRegistry(testAddress2, testPublicKey2);
+
+ const keys = getAllPublicKeys();
+ expect(keys.length).toBe(2);
+ });
+
+ it('should not include non-registry items', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ localStorage.setItem('other_key', 'other_value');
+
+ const keys = getAllPublicKeys();
+ expect(keys.length).toBe(1);
+ });
+ });
+
+ describe('clearPublicKeyRegistry', () => {
+ it('should clear all public keys', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ savePublicKeyToRegistry(testAddress2, testPublicKey2);
+
+ clearPublicKeyRegistry();
+
+ expect(getPublicKeyFromRegistry(testAddress1)).toBeNull();
+ expect(getPublicKeyFromRegistry(testAddress2)).toBeNull();
+ expect(getAllPublicKeys()).toEqual([]);
+ });
+
+ it('should not affect other localStorage items', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+ localStorage.setItem('other_key', 'other_value');
+
+ clearPublicKeyRegistry();
+
+ expect(localStorage.getItem('other_key')).toBe('other_value');
+ });
+
+ it('should handle empty registry', () => {
+ expect(() => {
+ clearPublicKeyRegistry();
+ }).not.toThrow();
+ });
+ });
+
+ describe('integration scenarios', () => {
+ it('should handle complete workflow', () => {
+ savePublicKeyToRegistry(testAddress1, testPublicKey1);
+
+ const retrieved = getPublicKeyFromRegistry(testAddress1);
+ expect(retrieved).toBe(testPublicKey1);
+
+ const allKeys = getAllPublicKeys();
+ expect(allKeys.length).toBe(1);
+
+ removePublicKeyFromRegistry(testAddress1);
+ expect(getPublicKeyFromRegistry(testAddress1)).toBeNull();
+ });
+
+ it('should handle multiple users', () => {
+ const addresses = [
+ 'SP1',
+ 'SP2',
+ 'SP3',
+ ];
+
+ addresses.forEach((addr, i) => {
+ savePublicKeyToRegistry(addr, `key-${i}`);
+ });
+
+ const allKeys = getAllPublicKeys();
+ expect(allKeys.length).toBe(3);
+
+ addresses.forEach((addr, i) => {
+ expect(getPublicKeyFromRegistry(addr)).toBe(`key-${i}`);
+ });
+ });
+ });
+});
diff --git a/frontend/src/test/useEncryption.test.js b/frontend/src/test/useEncryption.test.js
new file mode 100644
index 00000000..442b26d7
--- /dev/null
+++ b/frontend/src/test/useEncryption.test.js
@@ -0,0 +1,320 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useEncryption } from '../hooks/useEncryption';
+import * as encryption from '../lib/encryption';
+import * as publicKeyRegistry from '../lib/publicKeyRegistry';
+
+vi.mock('../lib/encryption');
+vi.mock('../lib/publicKeyRegistry');
+
+describe('useEncryption', () => {
+ const testAddress = 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7';
+ const recipientAddress = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE';
+ const mockKeys = {
+ publicKey: 'mock-public-key',
+ privateKey: 'mock-private-key',
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+
+ encryption.ensureUserKeys.mockResolvedValue(mockKeys);
+ encryption.loadKeysFromStorage.mockReturnValue(mockKeys);
+ encryption.deleteKeysFromStorage.mockImplementation(() => {});
+ encryption.encryptMessage.mockResolvedValue('ENC:encrypted');
+ encryption.decryptMessage.mockResolvedValue('decrypted');
+ encryption.isEncryptedMessage.mockImplementation(msg => msg?.startsWith('ENC:'));
+
+ publicKeyRegistry.savePublicKeyToRegistry.mockImplementation(() => {});
+ publicKeyRegistry.getPublicKeyFromRegistry.mockReturnValue('recipient-public-key');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('initialization', () => {
+ it('should initialize with loading state', () => {
+ const { result } = renderHook(() => useEncryption(testAddress));
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('should load keys for user address', async () => {
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(encryption.ensureUserKeys).toHaveBeenCalledWith(testAddress);
+ expect(result.current.keys).toEqual(mockKeys);
+ });
+
+ it('should save public key to registry', async () => {
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(publicKeyRegistry.savePublicKeyToRegistry).toHaveBeenCalledWith(
+ testAddress,
+ mockKeys.publicKey
+ );
+ });
+
+ it('should handle no user address', async () => {
+ const { result } = renderHook(() => useEncryption(null));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.keys).toBeNull();
+ expect(encryption.ensureUserKeys).not.toHaveBeenCalled();
+ });
+
+ it('should handle initialization error', async () => {
+ encryption.ensureUserKeys.mockRejectedValue(new Error('Init failed'));
+
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.error).toBe('Init failed');
+ expect(result.current.keys).toBeNull();
+ });
+ });
+
+ describe('encrypt', () => {
+ it('should encrypt message for recipient', async () => {
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const encrypted = await result.current.encrypt('test message', recipientAddress);
+
+ expect(encryption.encryptMessage).toHaveBeenCalledWith(
+ 'test message',
+ 'recipient-public-key'
+ );
+ expect(encrypted).toBe('ENC:encrypted');
+ });
+
+ it('should return message if empty', async () => {
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const encrypted = await result.current.encrypt('', recipientAddress);
+ expect(encrypted).toBe('');
+ expect(encryption.encryptMessage).not.toHaveBeenCalled();
+ });
+
+ it('should throw error if recipient key not found', async () => {
+ publicKeyRegistry.getPublicKeyFromRegistry.mockReturnValue(null);
+
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ await expect(
+ result.current.encrypt('test', recipientAddress)
+ ).rejects.toThrow('Recipient public key not found');
+ });
+ });
+
+ describe('decrypt', () => {
+ it('should decrypt encrypted message', async () => {
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const decrypted = await result.current.decrypt('ENC:encrypted');
+
+ expect(encryption.decryptMessage).toHaveBeenCalledWith(
+ 'ENC:encrypted',
+ mockKeys.privateKey
+ );
+ expect(decrypted).toBe('decrypted');
+ });
+
+ it('should return unencrypted message as is', async () => {
+ encryption.isEncryptedMessage.mockReturnValue(false);
+
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const result2 = await result.current.decrypt('plain text');
+ expect(result2).toBe('plain text');
+ expect(encryption.decryptMessage).not.toHaveBeenCalled();
+ });
+
+ it('should throw error if private key not available', async () => {
+ encryption.ensureUserKeys.mockResolvedValue({ publicKey: 'pub' });
+
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ await expect(
+ result.current.decrypt('ENC:encrypted')
+ ).rejects.toThrow('Private key not available');
+ });
+ });
+
+ describe('regenerateKeys', () => {
+ it('should regenerate keys', async () => {
+ const newKeys = {
+ publicKey: 'new-public-key',
+ privateKey: 'new-private-key',
+ };
+
+ encryption.ensureUserKeys
+ .mockResolvedValueOnce(mockKeys)
+ .mockResolvedValueOnce(newKeys);
+
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ await result.current.regenerateKeys();
+
+ await waitFor(() => {
+ expect(result.current.keys).toEqual(newKeys);
+ });
+
+ expect(encryption.deleteKeysFromStorage).toHaveBeenCalledWith(testAddress);
+ expect(publicKeyRegistry.savePublicKeyToRegistry).toHaveBeenCalledWith(
+ testAddress,
+ newKeys.publicKey
+ );
+ });
+
+ it('should handle regeneration error', async () => {
+ encryption.ensureUserKeys
+ .mockResolvedValueOnce(mockKeys)
+ .mockRejectedValueOnce(new Error('Regeneration failed'));
+
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ await result.current.regenerateKeys();
+
+ await waitFor(() => {
+ expect(result.current.error).toBe('Regeneration failed');
+ });
+ });
+
+ it('should not regenerate if no address', async () => {
+ const { result } = renderHook(() => useEncryption(null));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ await result.current.regenerateKeys();
+
+ expect(encryption.deleteKeysFromStorage).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('canEncryptFor', () => {
+ it('should return true if recipient key exists', async () => {
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const canEncrypt = result.current.canEncryptFor(recipientAddress);
+ expect(canEncrypt).toBe(true);
+ });
+
+ it('should return false if recipient key does not exist', async () => {
+ publicKeyRegistry.getPublicKeyFromRegistry.mockReturnValue(null);
+
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const canEncrypt = result.current.canEncryptFor(recipientAddress);
+ expect(canEncrypt).toBe(false);
+ });
+ });
+
+ describe('isEncrypted', () => {
+ it('should check if message is encrypted', async () => {
+ const { result } = renderHook(() => useEncryption(testAddress));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ result.current.isEncrypted('ENC:test');
+ expect(encryption.isEncryptedMessage).toHaveBeenCalledWith('ENC:test');
+ });
+ });
+
+ describe('address changes', () => {
+ it('should reload keys when address changes', async () => {
+ const { result, rerender } = renderHook(
+ ({ address }) => useEncryption(address),
+ { initialProps: { address: testAddress } }
+ );
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(encryption.ensureUserKeys).toHaveBeenCalledTimes(1);
+
+ const newAddress = 'SP_NEW_ADDRESS';
+ rerender({ address: newAddress });
+
+ await waitFor(() => {
+ expect(encryption.ensureUserKeys).toHaveBeenCalledWith(newAddress);
+ });
+ });
+
+ it('should clear keys when address becomes null', async () => {
+ const { result, rerender } = renderHook(
+ ({ address }) => useEncryption(address),
+ { initialProps: { address: testAddress } }
+ );
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ rerender({ address: null });
+
+ await waitFor(() => {
+ expect(result.current.keys).toBeNull();
+ });
+ });
+ });
+});