feat: add mnemonic recovery flow and encryption key management

- Introduced a new Mnemonic page for users to generate or import their recovery phrase.
- Updated routing to redirect users to the Mnemonic page after onboarding.
- Enhanced state management to store and retrieve AES encryption keys derived from the mnemonic.
- Added utility functions for generating and validating BIP39 mnemonic phrases.
- Updated selectors and reducers to handle encryption key state in the auth slice.
- Included new dependencies for cryptographic operations.
This commit is contained in:
M3gA-Mind
2026-02-06 18:16:08 +05:30
parent b277fb8151
commit e0cd0930a1
9 changed files with 455 additions and 4 deletions
+2
View File
@@ -31,7 +31,9 @@
"prepare": "husky"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
"@reduxjs/toolkit": "^2.11.2",
"@scure/bip39": "^2.0.1",
"@sentry/react": "^10.38.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "^2",
+22 -1
View File
@@ -7,18 +7,27 @@ import PublicRoute from './components/PublicRoute';
import SettingsModal from './components/settings/SettingsModal';
import Home from './pages/Home';
import Login from './pages/Login';
import Mnemonic from './pages/Mnemonic';
import Onboarding from './pages/onboarding/Onboarding';
import Welcome from './pages/Welcome';
import { selectIsOnboarded } from './store/authSelectors';
import { selectHasEncryptionKey, selectIsOnboarded } from './store/authSelectors';
import { useAppSelector } from './store/hooks';
import { isTauri } from './utils/tauriCommands';
const OnboardingRoute = () => {
const isOnboarded = useAppSelector(selectIsOnboarded);
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
if (isOnboarded && !hasEncryptionKey) return <Navigate to="/mnemonic" replace />;
if (isOnboarded) return <Navigate to="/home" replace />;
return <Onboarding />;
};
const MnemonicRoute = () => {
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
if (hasEncryptionKey) return <Navigate to="/home" replace />;
return <Mnemonic />;
};
/**
* Home route wrapper: shows Home by default.
* Only redirects to onboarding when user profile is loaded and onboarding is not done.
@@ -26,6 +35,7 @@ const OnboardingRoute = () => {
const HomeRoute = () => {
const user = useAppSelector(state => state.user.user);
const isOnboarded = useAppSelector(selectIsOnboarded);
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
// While user profile is still loading, show Home (avoid flash to onboarding)
if (!user) return <Home />;
@@ -33,6 +43,9 @@ const HomeRoute = () => {
// User loaded but onboarding not done → redirect to onboarding
if (!isOnboarded) return <Navigate to="/onboarding" replace />;
// Onboarded but no encryption key → redirect to mnemonic page
if (!hasEncryptionKey) return <Navigate to="/mnemonic" replace />;
return <Home />;
};
@@ -76,6 +89,14 @@ const AppRoutes = () => {
</ProtectedRoute>
}
/>
<Route
path="/mnemonic"
element={
<ProtectedRoute requireAuth={true}>
<MnemonicRoute />
</ProtectedRoute>
}
/>
<Route
path="/home"
element={
+360
View File
@@ -0,0 +1,360 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import LottieAnimation from '../components/LottieAnimation';
import { setEncryptionKeyForUser } from '../store/authSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
deriveAesKeyFromMnemonic,
generateMnemonicPhrase,
validateMnemonicPhrase,
} from '../utils/cryptoKeys';
const WORD_COUNT = 24;
const Mnemonic = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const [mode, setMode] = useState<'generate' | 'import'>('generate');
const [copied, setCopied] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Generate mode state
const mnemonic = useMemo(() => generateMnemonicPhrase(), []);
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
// Import mode state
const [importWords, setImportWords] = useState<string[]>(Array(WORD_COUNT).fill(''));
const [importValid, setImportValid] = useState<boolean | null>(null);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), 3000);
return () => clearTimeout(timer);
}
}, [copied]);
// Reset state when switching modes
useEffect(() => {
setConfirmed(false);
setError(null);
setImportValid(null);
}, [mode]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(mnemonic);
setCopied(true);
} catch {
const textarea = document.createElement('textarea');
textarea.value = mnemonic;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
}
}, [mnemonic]);
const handleImportWordChange = useCallback(
(index: number, value: string) => {
// Handle paste of full mnemonic phrase
const pastedWords = value.trim().split(/\s+/);
if (pastedWords.length > 1) {
const newWords = [...importWords];
for (let i = 0; i < Math.min(pastedWords.length, WORD_COUNT - index); i++) {
newWords[index + i] = pastedWords[i].toLowerCase();
}
setImportWords(newWords);
setImportValid(null);
// Focus the next empty field or the last filled field
const nextEmpty = newWords.findIndex(w => !w);
const focusIndex = nextEmpty === -1 ? WORD_COUNT - 1 : nextEmpty;
inputRefs.current[focusIndex]?.focus();
return;
}
const newWords = [...importWords];
newWords[index] = value.toLowerCase().trim();
setImportWords(newWords);
setImportValid(null);
// Auto-advance to next input when a word is entered
if (value.trim() && index < WORD_COUNT - 1) {
inputRefs.current[index + 1]?.focus();
}
},
[importWords],
);
const handleImportKeyDown = useCallback(
(index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !importWords[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
},
[importWords],
);
const handleValidateImport = useCallback(() => {
const phrase = importWords.join(' ').trim();
const filledWords = importWords.filter(w => w.trim());
if (filledWords.length !== WORD_COUNT) {
setError(`Please enter all ${WORD_COUNT} words.`);
setImportValid(false);
return false;
}
const isValid = validateMnemonicPhrase(phrase);
setImportValid(isValid);
if (!isValid) {
setError('Invalid recovery phrase. Please check your words and try again.');
return false;
}
setError(null);
return true;
}, [importWords]);
const handleContinue = async () => {
setError(null);
setLoading(true);
try {
let phraseToUse: string;
if (mode === 'import') {
if (!handleValidateImport()) {
setLoading(false);
return;
}
phraseToUse = importWords.join(' ').trim();
} else {
if (!confirmed) {
setLoading(false);
return;
}
phraseToUse = mnemonic;
}
const aesKey = deriveAesKeyFromMnemonic(phraseToUse);
if (user?._id) {
dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey }));
}
navigate('/home');
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.');
} finally {
setLoading(false);
}
};
const isImportComplete = importWords.every(w => w.trim());
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
return (
<div className="min-h-full relative flex items-center justify-center">
<div className="relative z-10 max-w-lg w-full mx-4">
<div className="flex justify-center mb-6">
<LottieAnimation src="/lottie/safe3.json" height={120} width={120} />
</div>
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
{mode === 'generate' ? (
<>
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Write down these 24 words in order and store them somewhere safe. This phrase is
used to encrypt your data and can never be recovered if lost.
</p>
</div>
{/* Mnemonic Grid */}
<div className="bg-stone-900/5 rounded-2xl p-4 mb-4">
<div className="grid grid-cols-3 gap-2">
{words.map((word, index) => (
<div
key={index}
className="flex items-center gap-2 bg-white/60 rounded-lg px-3 py-2 text-sm">
<span className="text-stone-400 font-mono text-xs w-5 text-right">
{index + 1}.
</span>
<span className="font-mono font-medium text-stone-800">{word}</span>
</div>
))}
</div>
</div>
{/* Copy Button */}
<button
onClick={handleCopy}
className="w-full flex items-center justify-center gap-2 border border-stone-200 hover:bg-stone-50 text-stone-700 font-medium py-2.5 text-sm rounded-xl transition-all duration-200 mb-3">
{copied ? (
<>
<svg
className="w-4 h-4 text-sage-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-sage-600">Copied to Clipboard</span>
</>
) : (
<>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<span>Copy to Clipboard</span>
</>
)}
</button>
{/* Import existing link */}
<button
onClick={() => setMode('import')}
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
I already have a recovery phrase
</button>
{/* Warning */}
<div className="flex items-start gap-3 bg-amber-50 border border-amber-200 rounded-xl p-3 mb-4">
<svg
className="w-5 h-5 text-amber-500 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<p className="text-xs text-amber-800">
Never share your recovery phrase with anyone. Anyone with these words can access
your encrypted data. AlphaHuman will never ask for your recovery phrase.
</p>
</div>
{/* Confirmation Checkbox */}
<label className="flex items-start gap-3 cursor-pointer mb-4">
<input
type="checkbox"
checked={confirmed}
onChange={e => setConfirmed(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-stone-300 text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-stone-600">
I have saved my recovery phrase in a safe place
</span>
</label>
</>
) : (
<>
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Enter your existing 24-word recovery phrase below. You can also paste the full
phrase into the first field.
</p>
</div>
{/* Import Word Inputs Grid */}
<div className="bg-stone-900/5 rounded-2xl p-4 mb-4">
<div className="grid grid-cols-3 gap-2">
{importWords.map((word, index) => (
<div key={index} className="flex items-center gap-1.5">
<span className="text-stone-400 font-mono text-xs w-5 text-right shrink-0">
{index + 1}.
</span>
<input
ref={el => {
inputRefs.current[index] = el;
}}
type="text"
value={word}
onChange={e => handleImportWordChange(index, e.target.value)}
onKeyDown={e => handleImportKeyDown(index, e)}
autoComplete="off"
spellCheck={false}
className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white/60 outline-none transition-colors ${
importValid === false && word.trim()
? 'border-coral-300 focus:border-coral-400'
: importValid === true
? 'border-sage-300 focus:border-sage-400'
: 'border-stone-200 focus:border-primary-400'
}`}
/>
</div>
))}
</div>
</div>
{/* Validation status */}
{importValid === true && (
<div className="flex items-center gap-2 text-sage-600 text-sm mb-3 justify-center">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Valid recovery phrase</span>
</div>
)}
{/* Back to generate link */}
<button
onClick={() => setMode('generate')}
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
Generate a new recovery phrase instead
</button>
</>
)}
{error && <p className="text-coral-500 text-sm mb-3 text-center">{error}</p>}
{/* Continue Button */}
<button
onClick={handleContinue}
disabled={!canContinue || loading}
className="w-full flex items-center justify-center space-x-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold py-2.5 text-sm rounded-xl transition-all duration-300 hover:shadow-medium">
<span>
{loading
? 'Securing Your Data...'
: mode === 'import'
? 'Import & Continue'
: "I'm Ready! Let's Go!"}
</span>
</button>
</div>
</div>
</div>
);
};
export default Mnemonic;
+1 -1
View File
@@ -46,7 +46,7 @@ const Onboarding = () => {
if (user?._id) {
dispatch(setOnboardedForUser({ userId: user._id, value: true }));
}
navigate('/home');
navigate('/mnemonic');
};
const renderStep = () => {
+6
View File
@@ -5,3 +5,9 @@ export const selectIsOnboarded = (state: RootState): boolean => {
if (!userId) return false;
return state.auth.isOnboardedByUser[userId] ?? false;
};
export const selectHasEncryptionKey = (state: RootState): boolean => {
const userId = state.user.user?._id;
if (!userId) return false;
return !!state.auth.encryptionKeyByUser[userId];
};
+10 -1
View File
@@ -9,12 +9,15 @@ export interface AuthState {
isOnboardedByUser: Record<string, boolean>;
/** Analytics consent per user id (opt-in during onboarding) */
isAnalyticsEnabledByUser: Record<string, boolean>;
/** AES encryption key (hex) derived from mnemonic, per user id */
encryptionKeyByUser: Record<string, string>;
}
const initialState: AuthState = {
token: null,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
encryptionKeyByUser: {},
};
const authSlice = createSlice({
@@ -26,6 +29,7 @@ const authSlice = createSlice({
},
_clearToken: state => {
state.token = null;
state.encryptionKeyByUser = {};
},
setOnboardedForUser: (state, action: PayloadAction<{ userId: string; value: boolean }>) => {
const { userId, value } = action.payload;
@@ -35,6 +39,10 @@ const authSlice = createSlice({
const { userId, enabled } = action.payload;
state.isAnalyticsEnabledByUser[userId] = enabled;
},
setEncryptionKeyForUser: (state, action: PayloadAction<{ userId: string; key: string }>) => {
const { userId, key } = action.payload;
state.encryptionKeyByUser[userId] = key;
},
},
});
@@ -45,5 +53,6 @@ export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispat
dispatch(clearTeamState());
});
export const { setToken, setOnboardedForUser, setAnalyticsForUser } = authSlice.actions;
export const { setToken, setOnboardedForUser, setAnalyticsForUser, setEncryptionKeyForUser } =
authSlice.actions;
export default authSlice.reducer;
+1 -1
View File
@@ -25,7 +25,7 @@ import userReducer from './userSlice';
const authPersistConfig = {
key: 'auth',
storage,
whitelist: ['token', 'isOnboardedByUser', 'isAnalyticsEnabledByUser'],
whitelist: ['token', 'isOnboardedByUser', 'isAnalyticsEnabledByUser', 'encryptionKeyByUser'],
};
// Persist config for AI state (config only)
+35
View File
@@ -0,0 +1,35 @@
import { pbkdf2 } from '@noble/hashes/pbkdf2.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
/**
* Generate a 24-word BIP39 mnemonic phrase (256-bit entropy).
*/
export function generateMnemonicPhrase(): string {
return generateMnemonic(wordlist, 256);
}
/**
* Validate a BIP39 mnemonic phrase.
*/
export function validateMnemonicPhrase(mnemonic: string): boolean {
return validateMnemonic(mnemonic, wordlist);
}
/**
* Derive a 256-bit AES encryption key from a mnemonic phrase.
* Uses BIP39 seed derivation followed by PBKDF2-SHA256.
* Returns the key as a hex string.
*/
export function deriveAesKeyFromMnemonic(mnemonic: string): string {
// Get the BIP39 seed (512-bit) from the mnemonic
const seed = mnemonicToSeedSync(mnemonic);
// Derive a 256-bit AES key using PBKDF2 with the seed
const salt = new TextEncoder().encode('alphahuman-aes-key-v1');
const derivedKey = pbkdf2(sha256, seed, salt, { c: 100000, dkLen: 32 });
return bytesToHex(derivedKey);
}
+18
View File
@@ -499,6 +499,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e"
integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -685,6 +690,19 @@
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
"@scure/base@2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98"
integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==
"@scure/bip39@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add"
integrity sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==
dependencies:
"@noble/hashes" "2.0.1"
"@scure/base" "2.0.0"
"@sentry-internal/browser-utils@10.38.0":
version "10.38.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.38.0.tgz#576780062808bd3bae21476393f50caf9acbe12f"