Added recovery keys
This commit is contained in:
37
TODO.md
37
TODO.md
@@ -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.
|
||||
|
||||
- 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 user’s password is effectively the root of all client-side encryption in a user’s account, forgetting or
|
||||
losing it results in the inability to decrypt the user’s 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.
|
||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -22,6 +22,7 @@ import type * as messages from "../messages.js";
|
||||
import type * as presence from "../presence.js";
|
||||
import type * as reactions from "../reactions.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 serverSettings from "../serverSettings.js";
|
||||
import type * as storageUrl from "../storageUrl.js";
|
||||
@@ -50,6 +51,7 @@ declare const fullApi: ApiFromModules<{
|
||||
presence: typeof presence;
|
||||
reactions: typeof reactions;
|
||||
readState: typeof readState;
|
||||
recovery: typeof recovery;
|
||||
roles: typeof roles;
|
||||
serverSettings: typeof serverSettings;
|
||||
storageUrl: typeof storageUrl;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
|
||||
@@ -284,3 +284,74 @@ export const updateStatus = mutation({
|
||||
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
70
convex/recovery.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
1
packages/shared/src/assets/icons/chat.svg
Normal file
1
packages/shared/src/assets/icons/chat.svg
Normal 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 |
1
packages/shared/src/assets/icons/eye.svg
Normal file
1
packages/shared/src/assets/icons/eye.svg
Normal 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 |
1
packages/shared/src/assets/icons/fullscreen.svg
Normal file
1
packages/shared/src/assets/icons/fullscreen.svg
Normal 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 |
1
packages/shared/src/assets/icons/popout.svg
Normal file
1
packages/shared/src/assets/icons/popout.svg
Normal 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 |
1
packages/shared/src/assets/icons/stop_sharing.svg
Normal file
1
packages/shared/src/assets/icons/stop_sharing.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
1
packages/shared/src/assets/icons/volume.svg
Normal file
1
packages/shared/src/assets/icons/volume.svg
Normal 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 |
@@ -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">
|
||||
|
||||
@@ -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
|
||||
========================================= */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
237
packages/shared/src/pages/Recovery.jsx
Normal file
237
packages/shared/src/pages/Recovery.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user