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

37
TODO.md
View File

@@ -5,3 +5,40 @@
- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile. - On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile.
- Add photo / video albums like Commit https://commet.chat/ - Add photo / video albums like Commit https://commet.chat/
- When resiszing and if im already at the bottom of the channel, i want to make sure i stay at the bottom of the channel.
- You should not be allowed to edit a image or video file upload message.
- Is their anyway we can show the users master key when they are logged in. Letting them download it to store it somewhere safe. Since if they forget their password its the only way we can techically recover their account. So this is what MEGA says on how they recover their users passwords
"As the users password is effectively the root of all client-side encryption in a users account, forgetting or
losing it results in the inability to decrypt the users data, which is highly destructive. For this reason, MEGA
allows and highly recommends users to export their “Recovery Key” (which is technically their Master Key).
MEGA clients detect when a user has not entered their password for a lengthy period of time (for example
due to enabling the “remember me” checkbox while logging in) and reminds users of the importance of their
password. This reminder dialog prompts the user to test their password and/or export their Recovery Key.
MEGA has a convenient recovery interface where novice users are guided based on their circumstances in case
of password loss: https://mega.nz/recovery
MEGA has found that users who forget or lose their password are often still logged in on another client (e.g. a
mobile app or MEGAsync). For this reason, MEGA allows users with an active session to change their password
in that client without first proving knowledge of the current password.
If the user has no other accessible active sessions, the user can use the Recovery Key (which is in effect the
Master Key) to reset the password of the account. Technically, the user would re-encrypt the Master Key with
a new password. Such a procedure requires email confirmation, so access to the Recovery Key alone is not
sufficient to breach a MEGA account"
We dont do emails. So as long as you have the master key we will allow you to reset your password.
- Lets make it so if i right click on a category i get a popup for that category for options like "Edit Category", "Delete Category".
- Lets make it so if i right click on someone on the memebers list or if they are in voice we get a couple more options. As is if they are in voice we get server mute and all that. Thats fine only when they are in voice but we should have more options for someone like, Change Nickname (If you have permission to change people nicknames), Message (To send them a direct message), Start a Call (To start a private call). Also this change nickname is for the whole server to see. So everywhere their username would be will be their nickname instead of their username. So if they have a nickname it will show up in the chat and in the members list instead of their username for everyone to see.

View File

@@ -22,6 +22,7 @@ import type * as messages from "../messages.js";
import type * as presence from "../presence.js"; import type * as presence from "../presence.js";
import type * as reactions from "../reactions.js"; import type * as reactions from "../reactions.js";
import type * as readState from "../readState.js"; import type * as readState from "../readState.js";
import type * as recovery from "../recovery.js";
import type * as roles from "../roles.js"; import type * as roles from "../roles.js";
import type * as serverSettings from "../serverSettings.js"; import type * as serverSettings from "../serverSettings.js";
import type * as storageUrl from "../storageUrl.js"; import type * as storageUrl from "../storageUrl.js";
@@ -50,6 +51,7 @@ declare const fullApi: ApiFromModules<{
presence: typeof presence; presence: typeof presence;
reactions: typeof reactions; reactions: typeof reactions;
readState: typeof readState; readState: typeof readState;
recovery: typeof recovery;
roles: typeof roles; roles: typeof roles;
serverSettings: typeof serverSettings; serverSettings: typeof serverSettings;
storageUrl: typeof storageUrl; storageUrl: typeof storageUrl;

View File

@@ -1,4 +1,4 @@
import { query, mutation } from "./_generated/server"; import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl"; import { getPublicStorageUrl } from "./storageUrl";
@@ -284,3 +284,74 @@ export const updateStatus = mutation({
return null; return null;
}, },
}); });
// Get encrypted private keys + public signing key for recovery
export const getRecoveryData = query({
args: { username: v.string() },
returns: v.union(
v.object({
encryptedPrivateKeys: v.string(),
publicSigningKey: v.string(),
}),
v.object({ error: v.string() })
),
handler: async (ctx, args) => {
const user = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (!user) {
return { error: "User not found" };
}
return {
encryptedPrivateKeys: user.encryptedPrivateKeys,
publicSigningKey: user.publicSigningKey,
};
},
});
// Internal: get userId + publicSigningKey for recovery action verification
export const getUserForRecovery = internalQuery({
args: { username: v.string() },
returns: v.union(
v.object({
userId: v.id("userProfiles"),
publicSigningKey: v.string(),
}),
v.null()
),
handler: async (ctx, args) => {
const user = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (!user) return null;
return {
userId: user._id,
publicSigningKey: user.publicSigningKey,
};
},
});
// Internal: update credentials after password reset
export const updateCredentials = internalMutation({
args: {
userId: v.id("userProfiles"),
clientSalt: v.string(),
encryptedMasterKey: v.string(),
hashedAuthKey: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
clientSalt: args.clientSalt,
encryptedMasterKey: args.encryptedMasterKey,
hashedAuthKey: args.hashedAuthKey,
});
return null;
},
});

70
convex/recovery.ts Normal file
View File

@@ -0,0 +1,70 @@
"use node";
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import crypto from "crypto";
export const resetPasswordAction = action({
args: {
username: v.string(),
newSalt: v.string(),
newEncryptedMK: v.string(),
newHAK: v.string(),
signature: v.string(),
timestamp: v.number(),
},
returns: v.union(
v.object({ success: v.boolean() }),
v.object({ error: v.string() })
),
handler: async (ctx, args) => {
// Validate timestamp is within 5 minutes
const now = Date.now();
if (Math.abs(now - args.timestamp) > 5 * 60 * 1000) {
return { error: "Request expired. Please try again." };
}
// Get user data
const userData = await ctx.runQuery(internal.auth.getUserForRecovery, {
username: args.username,
});
if (!userData) {
return { error: "User not found" };
}
// Verify Ed25519 signature
const message = `password-reset:${args.username}:${args.timestamp}`;
try {
const publicKeyObj = crypto.createPublicKey({
key: userData.publicSigningKey,
format: "pem",
type: "spki",
});
const isValid = crypto.verify(
null,
Buffer.from(message),
publicKeyObj,
Buffer.from(args.signature, "hex")
);
if (!isValid) {
return { error: "Invalid recovery key" };
}
} catch (e) {
return { error: "Signature verification failed" };
}
// Update credentials
await ctx.runMutation(internal.auth.updateCredentials, {
userId: userData.userId,
clientSalt: args.newSalt,
encryptedMasterKey: args.newEncryptedMK,
hashedAuthKey: args.newHAK,
});
return { success: true };
},
});

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import Recovery from './pages/Recovery';
import Chat from './pages/Chat'; import Chat from './pages/Chat';
import { usePlatform } from './platform'; import { usePlatform } from './platform';
import { useSearch } from './contexts/SearchContext'; import { useSearch } from './contexts/SearchContext';
@@ -37,6 +38,7 @@ function AuthGuard({ children }) {
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey); if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
sessionStorage.setItem('signingKey', savedSession.signingKey); sessionStorage.setItem('signingKey', savedSession.signingKey);
sessionStorage.setItem('privateKey', savedSession.privateKey); sessionStorage.setItem('privateKey', savedSession.privateKey);
if (savedSession.masterKey) sessionStorage.setItem('masterKey', savedSession.masterKey);
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey); if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
searchCtx?.initialize(); searchCtx?.initialize();
// Restore user preferences from file-based backup into localStorage // Restore user preferences from file-based backup into localStorage
@@ -70,7 +72,7 @@ function AuthGuard({ children }) {
useEffect(() => { useEffect(() => {
if (authState === 'loading') return; 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'); const hasSession = sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey');
if (hasSession && isAuthPage) { if (hasSession && isAuthPage) {
@@ -105,6 +107,7 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<Login />} /> <Route path="/" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/recovery" element={<Recovery />} />
<Route path="/chat" element={<Chat />} /> <Route path="/chat" element={<Chat />} />
</Routes> </Routes>
</AuthGuard> </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 typingTimeoutRef = useRef(null);
const lastTypingEmitRef = useRef(0); const lastTypingEmitRef = useRef(0);
const isInitialLoadRef = useRef(true); const isInitialLoadRef = useRef(true);
const initialScrollScheduledRef = useRef(false);
const decryptionDoneRef = useRef(false); const decryptionDoneRef = useRef(false);
const channelLoadIdRef = useRef(0); const channelLoadIdRef = useRef(0);
const jumpToMessageIdRef = useRef(null); const jumpToMessageIdRef = useRef(null);
@@ -559,6 +560,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const prevMessageCountRef = useRef(0); const prevMessageCountRef = useRef(0);
const prevFirstMsgIdRef = useRef(null); const prevFirstMsgIdRef = useRef(null);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
const isLoadingMoreRef = useRef(false);
const convex = useConvex(); const convex = useConvex();
@@ -576,6 +578,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
useEffect(() => { useEffect(() => {
statusRef.current = status; statusRef.current = status;
loadMoreRef.current = loadMore; loadMoreRef.current = loadMore;
if (status !== 'LoadingMore') {
isLoadingMoreRef.current = false;
}
}, [status, loadMore]); }, [status, loadMore]);
const typingData = useQuery( 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 // Phase 2: Batch-decrypt only uncached messages in background
const processUncached = async () => { const processUncached = async () => {
@@ -661,7 +680,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
decryptionDoneRef.current = true; decryptionDoneRef.current = true;
setDecryptedMessages(buildFromCache()); 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 loadId = channelLoadIdRef.current;
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; }; const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
requestAnimationFrame(() => requestAnimationFrame(() => { 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(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800); 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) scrollEnd(); }, 1200);
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
} }
} }
return; return;
@@ -786,7 +807,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// After decryption, items may be taller — re-scroll to bottom. // After decryption, items may be taller — re-scroll to bottom.
// Double-rAF waits for paint + ResizeObserver cycle; escalating timeouts are safety nets. // 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 loadId = channelLoadIdRef.current;
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; }; const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
requestAnimationFrame(() => requestAnimationFrame(() => { 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(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800); 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) 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; channelLoadIdRef.current += 1;
setDecryptedMessages([]); setDecryptedMessages([]);
isInitialLoadRef.current = true; isInitialLoadRef.current = true;
initialScrollScheduledRef.current = false;
decryptionDoneRef.current = false; decryptionDoneRef.current = false;
pingSeededRef.current = false; pingSeededRef.current = false;
notifiedMessageIdsRef.current = new Set(); notifiedMessageIdsRef.current = new Set();
@@ -837,6 +861,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setSlashQuery(null); setSlashQuery(null);
setEphemeralMessages([]); setEphemeralMessages([]);
floodAbortRef.current = true; floodAbortRef.current = true;
isLoadingMoreRef.current = false;
setFirstItemIndex(INITIAL_FIRST_INDEX); setFirstItemIndex(INITIAL_FIRST_INDEX);
prevMessageCountRef.current = 0; prevMessageCountRef.current = 0;
prevFirstMsgIdRef.current = null; prevFirstMsgIdRef.current = null;
@@ -1016,24 +1041,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// Virtuoso: startReached replaces IntersectionObserver // Virtuoso: startReached replaces IntersectionObserver
const handleStartReached = useCallback(() => { const handleStartReached = useCallback(() => {
if (isLoadingMoreRef.current) return;
if (statusRef.current === 'CanLoadMore') { if (statusRef.current === 'CanLoadMore') {
isLoadingMoreRef.current = true;
loadMoreRef.current(50); 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 // Virtuoso: followOutput auto-scrolls on new messages and handles initial load
const followOutput = useCallback((isAtBottom) => { const followOutput = useCallback((isAtBottom) => {
@@ -1068,12 +1082,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
if (channelLoadIdRef.current === loadId) { if (channelLoadIdRef.current === loadId) {
isInitialLoadRef.current = false; 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]); }, [markChannelAsRead]);
@@ -1668,26 +1678,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return ( return (
<> <>
{status === 'LoadingMore' && ( {status === 'LoadingMore' && (
<> <div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
{[ <div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
{ name: 80, lines: [260, 180] }, </div>
{ 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>
))}
</>
)} )}
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && ( {status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
<div className="channel-beginning"> <div className="channel-beginning">

View File

@@ -17,6 +17,7 @@ const THEME_PREVIEWS = {
const TABS = [ const TABS = [
{ id: 'account', label: 'My Account', section: 'USER SETTINGS' }, { id: 'account', label: 'My Account', section: 'USER SETTINGS' },
{ id: 'security', label: 'Security', section: 'USER SETTINGS' },
{ id: 'appearance', label: 'Appearance', section: 'USER SETTINGS' }, { id: 'appearance', label: 'Appearance', section: 'USER SETTINGS' },
{ id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' }, { id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' },
{ id: 'keybinds', label: 'Keybinds', 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, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}> <div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
{activeTab === 'account' && <MyAccountTab userId={userId} username={username} />} {activeTab === 'account' && <MyAccountTab userId={userId} username={username} />}
{activeTab === 'security' && <SecurityTab />}
{activeTab === 'appearance' && <AppearanceTab />} {activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'voice' && <VoiceVideoTab />} {activeTab === 'voice' && <VoiceVideoTab />}
{activeTab === 'keybinds' && <KeybindsTab />} {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 APPEARANCE TAB
========================================= */ ========================================= */

View File

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

View File

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