diff --git a/package.json b/package.json index c76e94153..e43d9b45a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 6cf59dc2a..227bd91df 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -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 ; if (isOnboarded) return ; return ; }; +const MnemonicRoute = () => { + const hasEncryptionKey = useAppSelector(selectHasEncryptionKey); + if (hasEncryptionKey) return ; + return ; +}; + /** * 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 ; @@ -33,6 +43,9 @@ const HomeRoute = () => { // User loaded but onboarding not done → redirect to onboarding if (!isOnboarded) return ; + // Onboarded but no encryption key → redirect to mnemonic page + if (!hasEncryptionKey) return ; + return ; }; @@ -76,6 +89,14 @@ const AppRoutes = () => { } /> + + + + } + /> { + 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(null); + + // Generate mode state + const mnemonic = useMemo(() => generateMnemonicPhrase(), []); + const words = useMemo(() => mnemonic.split(' '), [mnemonic]); + + // Import mode state + const [importWords, setImportWords] = useState(Array(WORD_COUNT).fill('')); + const [importValid, setImportValid] = useState(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) => { + 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 ( +
+
+
+ +
+ +
+ {mode === 'generate' ? ( + <> +
+

Your Recovery Phrase

+

+ 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. +

+
+ + {/* Mnemonic Grid */} +
+
+ {words.map((word, index) => ( +
+ + {index + 1}. + + {word} +
+ ))} +
+
+ + {/* Copy Button */} + + + {/* Import existing link */} + + + {/* Warning */} +
+ + + +

+ Never share your recovery phrase with anyone. Anyone with these words can access + your encrypted data. AlphaHuman will never ask for your recovery phrase. +

+
+ + {/* Confirmation Checkbox */} + + + ) : ( + <> +
+

Import Recovery Phrase

+

+ Enter your existing 24-word recovery phrase below. You can also paste the full + phrase into the first field. +

+
+ + {/* Import Word Inputs Grid */} +
+
+ {importWords.map((word, index) => ( +
+ + {index + 1}. + + { + 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' + }`} + /> +
+ ))} +
+
+ + {/* Validation status */} + {importValid === true && ( +
+ + + + Valid recovery phrase +
+ )} + + {/* Back to generate link */} + + + )} + + {error &&

{error}

} + + {/* Continue Button */} + +
+
+
+ ); +}; + +export default Mnemonic; diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index 173faa12f..e048d953c 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -46,7 +46,7 @@ const Onboarding = () => { if (user?._id) { dispatch(setOnboardedForUser({ userId: user._id, value: true })); } - navigate('/home'); + navigate('/mnemonic'); }; const renderStep = () => { diff --git a/src/store/authSelectors.ts b/src/store/authSelectors.ts index cfa13e7f1..4986f46c2 100644 --- a/src/store/authSelectors.ts +++ b/src/store/authSelectors.ts @@ -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]; +}; diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index 02b60af31..3180bfeb3 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -9,12 +9,15 @@ export interface AuthState { isOnboardedByUser: Record; /** Analytics consent per user id (opt-in during onboarding) */ isAnalyticsEnabledByUser: Record; + /** AES encryption key (hex) derived from mnemonic, per user id */ + encryptionKeyByUser: Record; } 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; diff --git a/src/store/index.ts b/src/store/index.ts index 859210ad7..03f7ac119 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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) diff --git a/src/utils/cryptoKeys.ts b/src/utils/cryptoKeys.ts new file mode 100644 index 000000000..7cf64964b --- /dev/null +++ b/src/utils/cryptoKeys.ts @@ -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); +} diff --git a/yarn.lock b/yarn.lock index 9da88127a..316fe5ecc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"