From 3b98951e5cbfb3e3e3d9ed47381293a784b46ce1 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 25 May 2026 01:12:14 +0100 Subject: [PATCH 01/13] Add encryption utility with RSA-OAEP implementation --- frontend/src/lib/encryption.js | 177 +++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 frontend/src/lib/encryption.js 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; +} From 108dd5828fe811e0aae180ccd293c67defd3eb24 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 25 May 2026 01:12:36 +0100 Subject: [PATCH 02/13] Add public key registry for storing recipient keys --- frontend/src/lib/publicKeyRegistry.js | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 frontend/src/lib/publicKeyRegistry.js 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)); +} From 0108af3f53b72d911346582e2b4fceaddbf2c909 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 25 May 2026 01:13:12 +0100 Subject: [PATCH 03/13] Add useEncryption hook for key management --- frontend/src/hooks/useEncryption.js | 100 ++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 frontend/src/hooks/useEncryption.js 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, + }; +} From 9df8e8885d211ddc20a2b39f31b52fa3add11621 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 25 May 2026 01:20:35 +0100 Subject: [PATCH 04/13] Add encryption toggle UI to SendTip component --- frontend/src/components/SendTip.jsx | 76 ++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 8 deletions(-) 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 && ( + + )} +