mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
@@ -46,7 +46,7 @@ const Onboarding = () => {
|
||||
if (user?._id) {
|
||||
dispatch(setOnboardedForUser({ userId: user._id, value: true }));
|
||||
}
|
||||
navigate('/home');
|
||||
navigate('/mnemonic');
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user