Added recovery keys

This commit is contained in:
Bryan1029384756
2026-02-18 09:24:53 -06:00
parent bebf0bf989
commit ce9902d95d
16 changed files with 642 additions and 44 deletions

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Recovery from './pages/Recovery';
import Chat from './pages/Chat';
import { usePlatform } from './platform';
import { useSearch } from './contexts/SearchContext';
@@ -37,6 +38,7 @@ function AuthGuard({ children }) {
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
sessionStorage.setItem('signingKey', savedSession.signingKey);
sessionStorage.setItem('privateKey', savedSession.privateKey);
if (savedSession.masterKey) sessionStorage.setItem('masterKey', savedSession.masterKey);
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
searchCtx?.initialize();
// Restore user preferences from file-based backup into localStorage
@@ -70,7 +72,7 @@ function AuthGuard({ children }) {
useEffect(() => {
if (authState === 'loading') return;
const isAuthPage = location.pathname === '/' || location.pathname === '/register';
const isAuthPage = location.pathname === '/' || location.pathname === '/register' || location.pathname === '/recovery';
const hasSession = sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey');
if (hasSession && isAuthPage) {
@@ -105,6 +107,7 @@ function App() {
<Routes>
<Route path="/" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/recovery" element={<Recovery />} />
<Route path="/chat" element={<Chat />} />
</Routes>
</AuthGuard>

View File

@@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24"><defs><mask id="789f9254-36b7-47b8-a1e8-6f324310eb42"><rect fill="white" width="100%" height="100%"></rect></mask></defs><g mask="url(#789f9254-36b7-47b8-a1e8-6f324310eb42)"><svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M12 22a10 10 0 1 0-8.45-4.64c.13.19.11.44-.04.61l-2.06 2.37A1 1 0 0 0 2.2 22H12Z" class=""></path></svg></g></svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -0,0 +1 @@
<svg class="viewersIcon_d6b206" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M15.56 11.77c.2-.1.44.02.44.23a4 4 0 1 1-4-4c.21 0 .33.25.23.44a2.5 2.5 0 0 0 3.32 3.32Z" class=""></path><path fill="currentColor" fill-rule="evenodd" d="M22.89 11.7c.07.2.07.4 0 .6C22.27 13.9 19.1 21 12 21c-7.11 0-10.27-7.11-10.89-8.7a.83.83 0 0 1 0-.6C1.73 10.1 4.9 3 12 3c7.11 0 10.27 7.11 10.89 8.7Zm-4.5-3.62A15.11 15.11 0 0 1 20.85 12c-.38.88-1.18 2.47-2.46 3.92C16.87 17.62 14.8 19 12 19c-2.8 0-4.87-1.38-6.39-3.08A15.11 15.11 0 0 1 3.15 12c.38-.88 1.18-2.47 2.46-3.92C7.13 6.38 9.2 5 12 5c2.8 0 4.87 1.38 6.39 3.08Z" clip-rule="evenodd" class=""></path></svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1 @@
<svg class="controlIcon_f1ceac" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M4 6c0-1.1.9-2 2-2h3a1 1 0 0 0 0-2H6a4 4 0 0 0-4 4v3a1 1 0 0 0 2 0V6ZM4 18c0 1.1.9 2 2 2h3a1 1 0 1 1 0 2H6a4 4 0 0 1-4-4v-3a1 1 0 1 1 2 0v3ZM18 4a2 2 0 0 1 2 2v3a1 1 0 1 0 2 0V6a4 4 0 0 0-4-4h-3a1 1 0 1 0 0 2h3ZM20 18a2 2 0 0 1-2 2h-3a1 1 0 1 0 0 2h3a4 4 0 0 0 4-4v-3a1 1 0 1 0-2 0v3Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@@ -0,0 +1 @@
<svg class="controlIcon_f1ceac" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M15 2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V4.41l-4.3 4.3a1 1 0 1 1-1.4-1.42L19.58 3H16a1 1 0 0 1-1-1Z" class=""></path><path fill="currentColor" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a1 1 0 1 0-2 0v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h6a1 1 0 1 0 0-2H5Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 475 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1 @@
<svg class="controlIcon_f1ceac" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="var(--interactive-icon-default)" d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.18 15.36c-.55.35-1.18-.12-1.18-.78v-.27c0-.36.2-.67.45-.93a2 2 0 0 0 0-2.76c-.24-.26-.45-.57-.45-.93v-.27c0-.66.63-1.13 1.18-.78a4 4 0 0 1 0 6.72Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -538,6 +538,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const typingTimeoutRef = useRef(null);
const lastTypingEmitRef = useRef(0);
const isInitialLoadRef = useRef(true);
const initialScrollScheduledRef = useRef(false);
const decryptionDoneRef = useRef(false);
const channelLoadIdRef = useRef(0);
const jumpToMessageIdRef = useRef(null);
@@ -559,6 +560,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const prevMessageCountRef = useRef(0);
const prevFirstMsgIdRef = useRef(null);
const isAtBottomRef = useRef(true);
const isLoadingMoreRef = useRef(false);
const convex = useConvex();
@@ -576,6 +578,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
useEffect(() => {
statusRef.current = status;
loadMoreRef.current = loadMore;
if (status !== 'LoadingMore') {
isLoadingMoreRef.current = false;
}
}, [status, loadMore]);
const typingData = useQuery(
@@ -632,7 +637,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
});
};
setDecryptedMessages(buildFromCache());
const newMessages = buildFromCache();
// Adjust firstItemIndex atomically with data to prevent Virtuoso scroll jump
const prevCount = prevMessageCountRef.current;
const newCount = newMessages.length;
if (newCount > prevCount && prevCount > 0) {
if (prevFirstMsgIdRef.current && newMessages[0]?.id !== prevFirstMsgIdRef.current) {
const prependedCount = newCount - prevCount;
setFirstItemIndex(prev => prev - prependedCount);
}
}
prevMessageCountRef.current = newCount;
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
setDecryptedMessages(newMessages);
// Phase 2: Batch-decrypt only uncached messages in background
const processUncached = async () => {
@@ -661,7 +680,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
decryptionDoneRef.current = true;
setDecryptedMessages(buildFromCache());
if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
initialScrollScheduledRef.current = true;
const loadId = channelLoadIdRef.current;
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
requestAnimationFrame(() => requestAnimationFrame(() => {
@@ -670,6 +690,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
}
}
return;
@@ -786,7 +807,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// After decryption, items may be taller — re-scroll to bottom.
// Double-rAF waits for paint + ResizeObserver cycle; escalating timeouts are safety nets.
if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
initialScrollScheduledRef.current = true;
const loadId = channelLoadIdRef.current;
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
requestAnimationFrame(() => requestAnimationFrame(() => {
@@ -795,6 +817,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
}
};
@@ -825,6 +848,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
channelLoadIdRef.current += 1;
setDecryptedMessages([]);
isInitialLoadRef.current = true;
initialScrollScheduledRef.current = false;
decryptionDoneRef.current = false;
pingSeededRef.current = false;
notifiedMessageIdsRef.current = new Set();
@@ -837,6 +861,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setSlashQuery(null);
setEphemeralMessages([]);
floodAbortRef.current = true;
isLoadingMoreRef.current = false;
setFirstItemIndex(INITIAL_FIRST_INDEX);
prevMessageCountRef.current = 0;
prevFirstMsgIdRef.current = null;
@@ -1016,24 +1041,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// Virtuoso: startReached replaces IntersectionObserver
const handleStartReached = useCallback(() => {
if (isLoadingMoreRef.current) return;
if (statusRef.current === 'CanLoadMore') {
isLoadingMoreRef.current = true;
loadMoreRef.current(50);
}
}, []);
// Virtuoso: firstItemIndex management for prepend without jitter
useEffect(() => {
const prevCount = prevMessageCountRef.current;
const newCount = decryptedMessages.length;
if (newCount > prevCount && prevCount > 0) {
if (prevFirstMsgIdRef.current && decryptedMessages[0]?.id !== prevFirstMsgIdRef.current) {
const prependedCount = newCount - prevCount;
setFirstItemIndex(prev => prev - prependedCount);
}
}
prevMessageCountRef.current = newCount;
prevFirstMsgIdRef.current = decryptedMessages[0]?.id || null;
}, [decryptedMessages]);
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
const followOutput = useCallback((isAtBottom) => {
@@ -1068,12 +1082,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
if (channelLoadIdRef.current === loadId) {
isInitialLoadRef.current = false;
}
}, 1500);
}, 300);
}
} else if (isInitialLoadRef.current && decryptionDoneRef.current) {
// Content resize pushed us off bottom during initial load — snap back
const el = scrollerElRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
}, [markChannelAsRead]);
@@ -1668,26 +1678,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return (
<>
{status === 'LoadingMore' && (
<>
{[
{ name: 80, lines: [260, 180] },
{ name: 60, lines: [310] },
{ name: 100, lines: [240, 140] },
{ name: 70, lines: [290] },
{ name: 90, lines: [200, 260] },
{ name: 55, lines: [330] },
].map((s, i) => (
<div key={i} className="skeleton-message" style={{ animationDelay: `${i * 0.1}s` }}>
<div className="skeleton-avatar" />
<div style={{ flex: 1 }}>
<div className="skeleton-name" style={{ width: s.name }} />
{s.lines.map((w, j) => (
<div key={j} className="skeleton-line" style={{ width: w }} />
))}
</div>
</div>
))}
</>
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
</div>
)}
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
<div className="channel-beginning">

View File

@@ -17,6 +17,7 @@ const THEME_PREVIEWS = {
const TABS = [
{ id: 'account', label: 'My Account', section: 'USER SETTINGS' },
{ id: 'security', label: 'Security', section: 'USER SETTINGS' },
{ id: 'appearance', label: 'Appearance', section: 'USER SETTINGS' },
{ id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' },
{ id: 'keybinds', label: 'Keybinds', section: 'APP SETTINGS' },
@@ -111,6 +112,7 @@ const UserSettings = ({ onClose, userId, username, onLogout }) => {
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
{activeTab === 'account' && <MyAccountTab userId={userId} username={username} />}
{activeTab === 'security' && <SecurityTab />}
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'voice' && <VoiceVideoTab />}
{activeTab === 'keybinds' && <KeybindsTab />}
@@ -550,6 +552,173 @@ const MyAccountTab = ({ userId, username }) => {
);
};
/* =========================================
SECURITY TAB
========================================= */
const SecurityTab = () => {
const [masterKey, setMasterKey] = useState(null);
const [revealed, setRevealed] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
const mk = sessionStorage.getItem('masterKey');
setMasterKey(mk);
}, []);
const formatKey = (hex) => {
if (!hex) return '';
return hex.match(/.{1,4}/g).join(' ');
};
const handleCopy = () => {
if (!masterKey) return;
navigator.clipboard.writeText(masterKey).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleDownload = () => {
if (!masterKey) return;
const content = [
'=== RECOVERY KEY ===',
'',
'This is your Recovery Key for your encrypted account.',
'Store it in a safe place. If you lose your password, this key is the ONLY way to recover your account.',
'',
'DO NOT share this key with anyone.',
'',
`Recovery Key: ${masterKey}`,
'',
`Exported: ${new Date().toISOString()}`,
].join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'recovery-key.txt';
a.click();
URL.revokeObjectURL(url);
};
const labelStyle = {
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
};
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Security</h2>
<div style={{ backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', padding: '20px' }}>
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 8px', fontSize: '16px', fontWeight: '600' }}>
Recovery Key
</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: '0 0 16px', lineHeight: '1.4' }}>
Your Recovery Key allows you to reset your password without losing access to your encrypted messages.
Store it somewhere safe if you forget your password, this is the <strong style={{ color: 'var(--text-normal)' }}>only way</strong> to recover your account.
</p>
{!masterKey ? (
<div style={{
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', padding: '16px',
color: 'var(--text-muted)', fontSize: '14px',
}}>
Recovery Key is not available in this session. Please log out and log back in to access it.
</div>
) : !revealed ? (
<div>
{/* Warning box */}
<div style={{
backgroundColor: 'rgba(250, 166, 26, 0.1)', border: '1px solid rgba(250, 166, 26, 0.4)',
borderRadius: '4px', padding: '12px', marginBottom: '16px',
}}>
<div style={{ color: '#faa61a', fontSize: '14px', fontWeight: '600', marginBottom: '4px' }}>
Warning
</div>
<div style={{ color: 'var(--text-normal)', fontSize: '13px', lineHeight: '1.4' }}>
Anyone with your Recovery Key can reset your password and take control of your account.
Only reveal it in a private, secure environment.
</div>
</div>
<label style={{
display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer',
color: 'var(--text-normal)', fontSize: '14px', marginBottom: '16px',
}}>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
style={{ width: '16px', height: '16px', accentColor: 'var(--brand-experiment)' }}
/>
I understand and want to reveal my Recovery Key
</label>
<button
onClick={() => setRevealed(true)}
disabled={!confirmed}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '10px 20px', cursor: confirmed ? 'pointer' : 'not-allowed',
fontSize: '14px', fontWeight: '500', opacity: confirmed ? 1 : 0.5,
}}
>
Reveal Recovery Key
</button>
</div>
) : (
<div>
<label style={labelStyle}>Your Recovery Key</label>
<div style={{
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', padding: '16px',
fontFamily: 'Consolas, "Courier New", monospace', fontSize: '15px',
color: 'var(--text-normal)', wordBreak: 'break-all', lineHeight: '1.6',
letterSpacing: '1px', marginBottom: '12px', userSelect: 'all',
}}>
{formatKey(masterKey)}
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button
onClick={handleCopy}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={handleDownload}
style={{
backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-normal)', border: 'none',
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Download
</button>
<button
onClick={() => { setRevealed(false); setConfirmed(false); }}
style={{
backgroundColor: 'transparent', color: 'var(--text-muted)', border: '1px solid var(--border-subtle)',
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Hide
</button>
</div>
</div>
)}
</div>
</div>
);
};
/* =========================================
APPEARANCE TAB
========================================= */

View File

@@ -2186,7 +2186,7 @@ body {
height: 48px;
display: flex;
align-items: center;
padding: 0 16px;
padding: 0 8px;
border-bottom: 1px solid var(--app-frame-border);
flex-shrink: 0;
font-weight: 600;
@@ -2205,6 +2205,9 @@ body {
border-radius: 4px;
padding: 4px 4px;
transition: background-color 0.1s;
font-size: 16px;
line-height: 1.25;
font-weight: 600;
}
.server-header-name:hover {

View File

@@ -55,6 +55,7 @@ const Login = () => {
console.log('Decrypting Master Key...');
const mkHex = await decryptEncryptedField(verifyData.encryptedMK, dek);
sessionStorage.setItem('masterKey', mkHex);
console.log('Decrypting Private Keys...');
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
@@ -80,6 +81,7 @@ const Login = () => {
publicKey: verifyData.publicKey || '',
signingKey,
privateKey: rsaPriv,
masterKey: mkHex,
searchDbKey: searchKeys.dak,
savedAt: Date.now(),
});
@@ -136,6 +138,11 @@ const Login = () => {
<div className="auth-footer">
Need an account? <Link to="/register">Register</Link>
</div>
<div style={{ textAlign: 'center', marginTop: '8px' }}>
<Link to="/recovery" style={{ color: 'var(--brand-experiment)', fontSize: '14px' }}>
Forgot your password?
</Link>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useConvex } from 'convex/react';
import { usePlatform } from '../platform';
import { api } from '../../../../convex/_generated/api';
const Recovery = () => {
const [step, setStep] = useState(1); // 1 = verify key, 2 = set new password
const [username, setUsername] = useState('');
const [recoveryKey, setRecoveryKey] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
// Stored after step 1 verification
const [verifiedMK, setVerifiedMK] = useState(null);
const [recoveredSigningKey, setRecoveredSigningKey] = useState(null);
const convex = useConvex();
const { crypto } = usePlatform();
async function decryptEncryptedField(encryptedJson, keyHex) {
const obj = JSON.parse(encryptedJson);
return crypto.decryptData(obj.content, keyHex, obj.iv, obj.tag);
}
const handleVerify = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
// Clean input: strip whitespace, dashes, non-hex chars
const cleanKey = recoveryKey.replace(/[\s\-]/g, '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(cleanKey)) {
throw new Error('Invalid Recovery Key format. Must be 64 hex characters.');
}
const data = await convex.query(api.auth.getRecoveryData, { username });
if (data.error) {
throw new Error(data.error);
}
// Try decrypting Ed25519 private key with provided MK
const encryptedPrivateKeysObj = JSON.parse(data.encryptedPrivateKeys);
let signingKey;
try {
signingKey = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.ed), cleanKey);
} catch {
throw new Error('Invalid Recovery Key. Decryption failed.');
}
// Store for step 2
setVerifiedMK(cleanKey);
setRecoveredSigningKey(signingKey);
setStep(2);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleReset = async (e) => {
e.preventDefault();
setError('');
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 1) {
setError('Password cannot be empty');
return;
}
setLoading(true);
try {
// Generate new salt and derive new keys
const newSalt = await crypto.randomBytes(16);
const { dek, dak } = await crypto.deriveAuthKeys(newPassword, newSalt);
// Re-encrypt master key with new DEK
const newEncryptedMK = JSON.stringify(await crypto.encryptData(verifiedMK, dek));
// Hash new DAK
const newHAK = await crypto.sha256(dak);
// Sign the reset message
const timestamp = Date.now();
const message = `password-reset:${username}:${timestamp}`;
const signature = await crypto.signMessage(recoveredSigningKey, message);
// Call the server action
const result = await convex.action(api.recovery.resetPasswordAction, {
username,
newSalt,
newEncryptedMK,
newHAK,
signature,
timestamp,
});
if (result.error) {
throw new Error(result.error);
}
setSuccess(true);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (success) {
return (
<div className="auth-container">
<div className="auth-box">
<div className="auth-header">
<h2>Password Reset Complete</h2>
<p>Your password has been changed successfully.</p>
</div>
<div style={{
backgroundColor: 'rgba(59, 165, 92, 0.1)', border: '1px solid rgba(59, 165, 92, 0.4)',
borderRadius: '4px', padding: '12px', marginBottom: '16px', textAlign: 'center',
}}>
<div style={{ color: '#3ba55c', fontSize: '14px' }}>
You can now log in with your new password. All your encrypted messages remain accessible.
</div>
</div>
<Link to="/" className="auth-button" style={{
display: 'block', textAlign: 'center', textDecoration: 'none',
}}>
Back to Login
</Link>
</div>
</div>
);
}
return (
<div className="auth-container">
<div className="auth-box">
<div className="auth-header">
<h2>Account Recovery</h2>
<p>{step === 1
? 'Enter your username and Recovery Key to verify your identity.'
: 'Set a new password for your account.'
}</p>
</div>
{error && <div style={{ color: '#ed4245', marginBottom: 10, textAlign: 'center', fontSize: '14px' }}>{error}</div>}
{step === 1 ? (
<form onSubmit={handleVerify}>
<div className="form-group">
<label>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="form-group">
<label>Recovery Key</label>
<textarea
value={recoveryKey}
onChange={(e) => setRecoveryKey(e.target.value)}
required
disabled={loading}
placeholder="Paste your 64-character Recovery Key"
rows={3}
style={{
fontFamily: 'Consolas, "Courier New", monospace',
fontSize: '14px',
resize: 'none',
width: '100%',
boxSizing: 'border-box',
}}
/>
</div>
<button type="submit" className="auth-button" disabled={loading}>
{loading ? 'Verifying...' : 'Verify Recovery Key'}
</button>
</form>
) : (
<form onSubmit={handleReset}>
<div style={{
backgroundColor: 'rgba(59, 165, 92, 0.1)', border: '1px solid rgba(59, 165, 92, 0.4)',
borderRadius: '4px', padding: '10px', marginBottom: '16px', textAlign: 'center',
}}>
<span style={{ color: '#3ba55c', fontSize: '14px' }}>
Recovery Key verified for <strong>{username}</strong>
</span>
</div>
<div className="form-group">
<label>New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="form-group">
<label>Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<button type="submit" className="auth-button" disabled={loading}>
{loading ? 'Resetting Password...' : 'Reset Password'}
</button>
</form>
)}
<div className="auth-footer">
Remember your password? <Link to="/">Log In</Link>
</div>
</div>
</div>
);
};
export default Recovery;