From a77ea93fad85d7c8157b28fe75ccd2aac01d1192 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 9 Dec 2025 10:19:15 -0300 Subject: [PATCH 1/6] [Vulnerability 001] - fix: solve Private Keys Encrypted With Hashed Password Instead of Password --- .../Background/controllers/MainController.ts | 159 +++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) diff --git a/source/scripts/Background/controllers/MainController.ts b/source/scripts/Background/controllers/MainController.ts index a389e399e..0e151d484 100644 --- a/source/scripts/Background/controllers/MainController.ts +++ b/source/scripts/Background/controllers/MainController.ts @@ -34,6 +34,7 @@ import { clearNavigationState } from '../../../utils/navigationState'; import { checkForUpdates } from '../handlers/handlePaliUpdates'; import PaliLogo from 'assets/all_assets/favicon-32.png'; import { ASSET_PRICE_API } from 'constants/index'; +import { loadSlip44State, saveSlip44State } from 'state/paliStorage'; import { setPrices } from 'state/price'; import store from 'state/store'; import { loadAndActivateSlip44Vault, saveMainState } from 'state/store'; @@ -1696,7 +1697,8 @@ class MainController { } this.lockAllKeyrings(); - const { canLogin, needsAccountCreation } = await this.unlock(pwd); + const { canLogin, needsAccountCreation, needsXprvMigration } = + await this.unlock(pwd); if (!canLogin) { console.error('[MainController] Unlock failed - invalid password'); @@ -1750,6 +1752,161 @@ class MainController { } } + // Handle xprv migration from hash-based to PBKDF2-based encryption + // IMPORTANT: Migrate ALL slip44 vaults, not just the active one + if (needsXprvMigration) { + console.log( + '[MainController] Migrating xprv values to PBKDF2-based encryption...' + ); + + try { + const keyring = this.getActiveKeyring(); + const activeSlip44 = store.getState().vaultGlobal.activeSlip44; + let totalMigratedCount = 0; + + // Helper function to migrate accounts in a vault state + const migrateVaultAccounts = ( + accounts: any + ): { count: number; migratedAccounts: any } => { + const migratedAccounts = { ...accounts }; + let count = 0; + + for (const accountType of Object.values(KeyringAccountType)) { + const accountsOfType = accounts[accountType]; + if (!accountsOfType) continue; + + migratedAccounts[accountType] = { ...accountsOfType }; + + for (const accountId of Object.keys(accountsOfType)) { + const id = Number(accountId); + const account = accountsOfType[id]; + + // Skip accounts without xprv (e.g., watch-only, Trezor, Ledger) + if (!account?.xprv || account.xprv === '') continue; + + try { + // Migrate the xprv from legacy encryption to new PBKDF2-based encryption + const migratedXprv = keyring.migrateXprv(account.xprv); + migratedAccounts[accountType][id] = { + ...account, + xprv: migratedXprv, + }; + count++; + console.log( + `[MainController] Migrated xprv for ${accountType}:${id}` + ); + } catch (migrateError) { + console.error( + `[MainController] Failed to migrate xprv for ${accountType}:${id}:`, + migrateError + ); + // Keep original account data if migration fails + } + } + } + + return { migratedAccounts, count }; + }; + + // 1. Migrate active vault (current Redux state) + const activeVaultState = store.getState().vault; + const { migratedAccounts: activeAccounts, count: activeCount } = + migrateVaultAccounts(activeVaultState.accounts); + + // Update Redux store with migrated accounts + for (const accountType of Object.values(KeyringAccountType)) { + const accountsOfType = activeAccounts[accountType]; + if (!accountsOfType) continue; + + for (const accountId of Object.keys(accountsOfType)) { + const id = Number(accountId); + const account = accountsOfType[id]; + const originalAccount = + activeVaultState.accounts[accountType]?.[id]; + + // Only dispatch if xprv actually changed + if (originalAccount && account.xprv !== originalAccount.xprv) { + store.dispatch( + setAccountPropertyByIdAndType({ + id, + type: accountType, + property: 'xprv', + value: account.xprv, + }) + ); + } + } + } + totalMigratedCount += activeCount; + + // Save active vault state + await this.saveWalletState('xprv-migration-active', true, true); + + // 2. Migrate OTHER slip44 vaults stored in paliStorage + const slip44sToMigrate = [DEFAULT_UTXO_SLIP44, DEFAULT_EVM_SLIP44]; + + for (const slip44 of slip44sToMigrate) { + // Skip the active slip44 (already migrated above) + if (slip44 === activeSlip44) continue; + + try { + const storedVaultState = await loadSlip44State(slip44); + if (!storedVaultState || !storedVaultState.accounts) { + console.log( + `[MainController] No vault found for slip44 ${slip44}, skipping` + ); + continue; + } + + console.log( + `[MainController] Migrating slip44 ${slip44} vault from storage...` + ); + + const { migratedAccounts, count } = migrateVaultAccounts( + storedVaultState.accounts + ); + + if (count > 0) { + // Save the migrated vault back to storage + const migratedVaultState = { + ...storedVaultState, + accounts: migratedAccounts, + }; + await saveSlip44State(slip44, migratedVaultState); + totalMigratedCount += count; + + // Also clear the vault cache so it reloads the migrated data + vaultCache.clearSlip44FromCache(slip44); + + console.log( + `[MainController] Migrated ${count} accounts for slip44 ${slip44}` + ); + } + } catch (slip44Error) { + console.error( + `[MainController] Failed to migrate slip44 ${slip44} vault:`, + slip44Error + ); + // Continue with other slip44s even if one fails + } + } + + // Clear the legacy session password after ALL migrations are complete + keyring.clearLegacySession(); + + console.log( + `[MainController] xprv migration completed. Migrated ${totalMigratedCount} accounts total.` + ); + } catch (migrationError) { + console.error( + '[MainController] Error during xprv migration:', + migrationError + ); + // Don't throw - allow unlock to proceed even if migration fails + // The fallback decryption will continue to work + } + } + // REPAIR CHECK: Detect and fix accounts with missing xpub/xprv await this.repairCorruptedAccounts(); From a762758fe1355d85da5a4993387d3dcdaa5c6940 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 9 Dec 2025 10:49:24 -0300 Subject: [PATCH 2/6] [Vulnerability 003] - fix: solve Stealing Seedphrase through Clipboard Attacks --- source/components/Input/SeedPhraseDisplay.tsx | 82 +++---------------- source/pages/SeedConfirm/CreatePhrase.tsx | 14 ---- source/pages/Settings/ForgetWallet.tsx | 26 +----- source/pages/Settings/Phrase.tsx | 24 +----- source/pages/Settings/PrivateKey.tsx | 52 +++++++++--- 5 files changed, 54 insertions(+), 144 deletions(-) diff --git a/source/components/Input/SeedPhraseDisplay.tsx b/source/components/Input/SeedPhraseDisplay.tsx index 6b4612662..d864eae5f 100644 --- a/source/components/Input/SeedPhraseDisplay.tsx +++ b/source/components/Input/SeedPhraseDisplay.tsx @@ -1,7 +1,5 @@ import { isEmpty } from 'lodash'; -import React, { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { BiCopy } from 'react-icons/bi'; +import React, { useState } from 'react'; interface ISeedPhraseDisplayProps { /** @@ -9,11 +7,6 @@ interface ISeedPhraseDisplayProps { */ className?: string; - /** - * Whether the phrase was successfully copied (for UI feedback) - */ - copied?: boolean; - /** * Whether to use the TextArea style (ForgetWallet) or word-by-word style (Phrase) */ @@ -24,11 +17,6 @@ interface ISeedPhraseDisplayProps { */ isEnabled?: boolean; - /** - * Callback when copy is clicked - */ - onCopy: (phrase: string) => void; - /** * Placeholder text when seed phrase is not available */ @@ -49,13 +37,10 @@ export const SeedPhraseDisplay: React.FC = ({ seedPhrase, isEnabled = true, showEyeToggle = true, - onCopy, - copied = false, placeholder, className = '', displayMode = 'words', }) => { - const { t } = useTranslation(); const [visible, setVisible] = useState(false); const mockedPhrase = @@ -63,20 +48,13 @@ export const SeedPhraseDisplay: React.FC = ({ const displayPhrase = isEmpty(seedPhrase) ? placeholder || mockedPhrase : seedPhrase; - const canCopy = isEnabled && seedPhrase && !isEmpty(seedPhrase); - const handleCopy = useCallback(() => { - if (canCopy && seedPhrase) { - onCopy(seedPhrase); - } - }, [canCopy, seedPhrase, onCopy]); - - const toggleVisible = useCallback(() => { + const toggleVisible = () => { setVisible((prev) => !prev); - }, []); + }; if (displayMode === 'textarea') { - // ForgetWallet style - TextArea with copy icon + // ForgetWallet style - TextArea (view-only, no copy to prevent clipboard attacks) return (
@@ -87,13 +65,13 @@ export const SeedPhraseDisplay: React.FC = ({ : 'opacity-100 bg-fields-input-primary' } ${ showEyeToggle && !visible ? 'filter blur-sm' : '' - } p-2 pl-4 pr-12 w-full h-[90px] text-brand-graylight text-sm border border-border-default focus:border-fields-input-borderfocus rounded-[10px] outline-none resize-none`} - placeholder={!isEnabled ? t('settings.enterYourPassword') : ''} + } p-2 pl-4 pr-12 w-full h-[90px] text-brand-graylight text-sm border border-border-default focus:border-fields-input-borderfocus rounded-[10px] outline-none resize-none select-none`} + placeholder={!isEnabled ? 'Enter your password' : ''} value={displayPhrase} readOnly={true} /> - {/* Controls container - eye toggle and copy button */} + {/* Controls container - eye toggle only (copy removed for security) */}
{/* Eye toggle - only show when showEyeToggle is true and seed is available */} {showEyeToggle && seedPhrase && ( @@ -118,39 +96,19 @@ export const SeedPhraseDisplay: React.FC = ({ )} )} - - {/* Copy button - only show when enabled and seed is available */} - {canCopy && ( - - )}
); } - // Phrase style - Word-by-word with eye toggle and copy button inside the box + // Phrase style - Word-by-word (view-only, no copy to prevent clipboard attacks) return (
{/* Seed phrase display box */}
@@ -161,7 +119,7 @@ export const SeedPhraseDisplay: React.FC = ({ ))}
- {/* Controls container - eye toggle and copy button */} + {/* Controls container - eye toggle only (copy removed for security) */}
{/* Eye toggle - only show when enabled and showEyeToggle is true */} {showEyeToggle && seedPhrase && ( @@ -186,26 +144,6 @@ export const SeedPhraseDisplay: React.FC = ({ )} )} - - {/* Copy button inside the box */} - {canCopy && ( - - )}
diff --git a/source/pages/SeedConfirm/CreatePhrase.tsx b/source/pages/SeedConfirm/CreatePhrase.tsx index 459907136..dbf21f55d 100644 --- a/source/pages/SeedConfirm/CreatePhrase.tsx +++ b/source/pages/SeedConfirm/CreatePhrase.tsx @@ -12,7 +12,6 @@ export const CreatePhrase = ({ password }: { password: string }) => { const [wordCount, setWordCount] = useState(12); const { t } = useTranslation(); - const [copied, setCopied] = useState(false); const [isTermsConfirmed, setIsTermsConfirmed] = useState(false); const navigate = useNavigate(); @@ -25,17 +24,6 @@ export const CreatePhrase = ({ password }: { password: string }) => { ); }, [controllerEmitter, wordCount]); - const handleCopyToClipboard = useCallback(async (seedPhrase: string) => { - try { - await navigator.clipboard.writeText(seedPhrase); - setCopied(true); - // Reset copied state after a delay - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy seed:', error); - } - }, []); - const handleNext = useCallback(() => { if (isTermsConfirmed) { navigate('/phrase', { @@ -73,8 +61,6 @@ export const CreatePhrase = ({ password }: { password: string }) => { seedPhrase={seed} isEnabled={true} showEyeToggle={true} - onCopy={handleCopyToClipboard} - copied={copied} displayMode="words" className="w-[17.5rem] max-w-[17.5rem]" /> diff --git a/source/pages/Settings/ForgetWallet.tsx b/source/pages/Settings/ForgetWallet.tsx index 524d89894..19e7781a7 100644 --- a/source/pages/Settings/ForgetWallet.tsx +++ b/source/pages/Settings/ForgetWallet.tsx @@ -16,7 +16,7 @@ import { RootState } from 'state/store'; import { INetworkType } from 'types/network'; const ForgetWalletView = () => { - const { navigate, alert } = useUtils(); + const { navigate } = useUtils(); const { t } = useTranslation(); const { controllerEmitter } = useController(); const isBitcoinBased = useSelector( @@ -37,15 +37,12 @@ const ForgetWalletView = () => { // Loading state for submit action const [isSubmitting, setIsSubmitting] = useState(false); - // Cached seed for copying + // Cached seed for display (view-only) const [cachedSeed, setCachedSeed] = useState(''); // Password validation state const [isPasswordValid, setIsPasswordValid] = useState(false); - // Copy state for SeedPhraseDisplay - const [copied, setCopied] = useState(false); - const [form] = Form.useForm(); // Password validation function for ValidatedPasswordInput @@ -81,23 +78,6 @@ const ForgetWalletView = () => { } }, [hasAccountFunds, form]); - // Copy seed phrase to clipboard using SeedPhraseDisplay - const handleCopySeed = useCallback( - async (seedPhrase: string) => { - try { - await navigator.clipboard.writeText(seedPhrase); - alert.success(t('settings.seedPhraseCopied')); - setCopied(true); - // Reset copied state after a delay - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy seed:', error); - alert.error(t('buttons.error')); - } - }, - [alert, t] - ); - const onSubmit = useCallback( async ({ password }: { password: string }) => { setIsSubmitting(true); @@ -171,8 +151,6 @@ const ForgetWalletView = () => { seedPhrase={cachedSeed} isEnabled={isPasswordValid} showEyeToggle={true} - onCopy={handleCopySeed} - copied={copied} displayMode="textarea" /> diff --git a/source/pages/Settings/Phrase.tsx b/source/pages/Settings/Phrase.tsx index 19d256f47..99253205d 100644 --- a/source/pages/Settings/Phrase.tsx +++ b/source/pages/Settings/Phrase.tsx @@ -19,11 +19,8 @@ const PhraseView = () => { // Password validation state like ForgetWallet const [isPasswordValid, setIsPasswordValid] = useState(false); - // Copy state for SeedPhraseDisplay - const [copied, setCopied] = useState(false); - const { t } = useTranslation(); - const { navigate, alert } = useUtils(); + const { navigate } = useUtils(); const location = useLocation(); const { controllerEmitter } = useController(); @@ -50,23 +47,6 @@ const PhraseView = () => { setIsPasswordValid(false); }, []); - // Copy seed phrase to clipboard using alert like ForgetWallet - const handleCopyToClipboard = useCallback( - async (seedPhrase: string) => { - try { - await navigator.clipboard.writeText(seedPhrase); - alert.success(t('settings.seedPhraseCopied')); - setCopied(true); - // Reset copied state after a delay - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy seed:', error); - alert.error(t('buttons.error')); - } - }, - [alert, t] - ); - // Navigation callback const handleClose = useCallback(() => { navigateBack(navigate, location); @@ -102,8 +82,6 @@ const PhraseView = () => { seedPhrase={phrase} isEnabled={isPasswordValid} showEyeToggle={true} - onCopy={handleCopyToClipboard} - copied={copied} displayMode="textarea" /> diff --git a/source/pages/Settings/PrivateKey.tsx b/source/pages/Settings/PrivateKey.tsx index e12200b9b..1869e770a 100644 --- a/source/pages/Settings/PrivateKey.tsx +++ b/source/pages/Settings/PrivateKey.tsx @@ -29,6 +29,8 @@ const PrivateKeyView = () => { const [copied, copyText] = useCopyClipboard(); const [valid, setValid] = useState(false); const [currentXprv, setCurrentXprv] = useState(''); + const [isPrivateKeyVisible, setIsPrivateKeyVisible] = + useState(false); const [form] = Form.useForm(); const getDecryptedPrivateKey = async (key: string) => { @@ -128,17 +130,45 @@ const PrivateKeyView = () => { /> - copyText(currentXprv) : undefined} - label={t('settings.yourPrivateKey')} - > -

- {valid && activeAccount.xpub - ? ellipsis(currentXprv, 4, 16) - : '********...************'} -

-
+ {/* Private key display - view-only, no copy to prevent clipboard attacks */} +
+
+

{t('settings.yourPrivateKey')}

+ {valid && currentXprv && ( + + )} +
+
+

+ {valid && activeAccount.xpub + ? ellipsis(currentXprv, 4, 16) + : '********...************'} +

+
+
{isBitcoinBased && (
From 0c943200a35d3d5a3044830ba23b11076382174c Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 9 Dec 2025 11:00:09 -0300 Subject: [PATCH 3/6] [Vulnerability 002] - fix: solve Low Password Complexity Threshold --- source/assets/locales/de.json | 4 ++-- source/assets/locales/en.json | 4 ++-- source/assets/locales/es.json | 4 ++-- source/assets/locales/fr.json | 4 ++-- source/assets/locales/ja.json | 4 ++-- source/assets/locales/ko.json | 4 ++-- source/assets/locales/pt.json | 4 ++-- source/assets/locales/ru.json | 4 ++-- source/assets/locales/zh.json | 4 ++-- source/components/PasswordForm/PasswordForm.tsx | 4 +++- 10 files changed, 21 insertions(+), 19 deletions(-) diff --git a/source/assets/locales/de.json b/source/assets/locales/de.json index f4100a1f0..99bf196a4 100644 --- a/source/assets/locales/de.json +++ b/source/assets/locales/de.json @@ -217,9 +217,9 @@ "addressType": "Adresstyp" }, "components": { - "newPassword": "Neues Passwort (min. 8 Zeichen)", + "newPassword": "Neues Passwort (min. 12 Zeichen)", "confirmPassword": "Passwort bestätigen", - "atLeast": "Mindestens 8 Zeichen, 1 Kleinbuchstabe und 1 Ziffer.", + "atLeast": "Mindestens 12 Zeichen, 1 Großbuchstabe, 1 Kleinbuchstabe, 1 Ziffer und 1 Sonderzeichen (!@#$%^&*...).", "doNotForget": "Vergessen Sie nicht, Ihr Passwort zu speichern. Sie benötigen dieses Passwort, um Ihre Wallet zu entsperren.", "feeRateLabel": "Gebührenrate ({{currency}}/Byte)", "gasPriceLabel": "Gaspreis (gwei)", diff --git a/source/assets/locales/en.json b/source/assets/locales/en.json index 023ef0877..7874bffe7 100644 --- a/source/assets/locales/en.json +++ b/source/assets/locales/en.json @@ -39,9 +39,9 @@ "generate": "Generate" }, "components": { - "newPassword": "New password (min 8 chars)", + "newPassword": "New password (min 12 chars)", "confirmPassword": "Confirm password", - "atLeast": "At least 8 characters, 1 lower-case and 1 numeral.", + "atLeast": "At least 12 characters, 1 uppercase, 1 lowercase, 1 number, and 1 special character (!@#$%^&*...).", "doNotForget": "Do not forget to save your password. You will need this password to unlock your wallet.", "feeRateLabel": "Fee Rate ({{currency}}/byte)", "gasPriceLabel": "Gas Price (gwei)", diff --git a/source/assets/locales/es.json b/source/assets/locales/es.json index d636c6f1d..e4a4fc8bc 100644 --- a/source/assets/locales/es.json +++ b/source/assets/locales/es.json @@ -39,9 +39,9 @@ "generate": "Generar" }, "components": { - "newPassword": "Nueva contraseña (mín. 8 caracteres)", + "newPassword": "Nueva contraseña (mín. 12 caracteres)", "confirmPassword": "Confirmar contraseña", - "atLeast": "Al menos 8 caracteres, 1 en minúscula y 1 numérico.", + "atLeast": "Al menos 12 caracteres, 1 mayúscula, 1 minúscula, 1 número y 1 carácter especial (!@#$%^&*...).", "doNotForget": "No olvides guardar tu contraseña. Necesitarás esta contraseña para desbloquear tu billetera.", "feeRateLabel": "Tarifa ({{currency}}/byte)", "gasPriceLabel": "Precio del Gas (gwei)", diff --git a/source/assets/locales/fr.json b/source/assets/locales/fr.json index 92b051910..7544c4855 100644 --- a/source/assets/locales/fr.json +++ b/source/assets/locales/fr.json @@ -217,9 +217,9 @@ "addressType": "Type d'adresse" }, "components": { - "newPassword": "Nouveau mot de passe (8 caractères min.)", + "newPassword": "Nouveau mot de passe (12 caractères min.)", "confirmPassword": "Confirmer le mot de passe", - "atLeast": "Au moins 8 caractères, 1 en minuscule et 1 chiffre.", + "atLeast": "Au moins 12 caractères, 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial (!@#$%^&*...).", "doNotForget": "N'oubliez pas de sauvegarder votre mot de passe. Vous aurez besoin de ce mot de passe pour déverrouiller votre portefeuille.", "feeRateLabel": "Tarif de frais ({{currency}}/byte)", "gasPriceLabel": "Prix du gaz (gwei)", diff --git a/source/assets/locales/ja.json b/source/assets/locales/ja.json index 6b001bf3e..78a60c17c 100644 --- a/source/assets/locales/ja.json +++ b/source/assets/locales/ja.json @@ -217,9 +217,9 @@ "addressType": "アドレスタイプ" }, "components": { - "newPassword": "新しいパスワード(最低8文字)", + "newPassword": "新しいパスワード(最低12文字)", "confirmPassword": "パスワードを確認", - "atLeast": "最低8文字、小文字1文字、数字1文字。", + "atLeast": "最低12文字、大文字1文字、小文字1文字、数字1文字、特殊文字1文字(!@#$%^&*...)。", "doNotForget": "パスワードを保存することを忘れないでください。ウォレットのロックを解除するには、このパスワードが必要です。", "feeRateLabel": "手数料レート ({{currency}}/byte)", "gasPriceLabel": "ガス価格 (gwei)", diff --git a/source/assets/locales/ko.json b/source/assets/locales/ko.json index 47238caa0..ec7d5251d 100644 --- a/source/assets/locales/ko.json +++ b/source/assets/locales/ko.json @@ -217,9 +217,9 @@ "addressType": "주소 유형" }, "components": { - "newPassword": "새 비밀번호 (최소 8자)", + "newPassword": "새 비밀번호 (최소 12자)", "confirmPassword": "비밀번호 확인", - "atLeast": "최소 8자, 소문자 1개, 숫자 1개.", + "atLeast": "최소 12자, 대문자 1개, 소문자 1개, 숫자 1개, 특수문자 1개 (!@#$%^&*...).", "doNotForget": "비밀번호를 저장하는 것을 잊지 마세요. 지갑을 잠금 해제하려면 이 비밀번호가 필요합니다.", "feeRateLabel": "수수료율 ({{currency}}/바이트)", "gasPriceLabel": "가스 가격 (gwei)", diff --git a/source/assets/locales/pt.json b/source/assets/locales/pt.json index 158341c6f..ffab2f975 100644 --- a/source/assets/locales/pt.json +++ b/source/assets/locales/pt.json @@ -39,9 +39,9 @@ "generate": "Gerar" }, "components": { - "newPassword": "Nova senha (mín. 8 caracteres)", + "newPassword": "Nova senha (mín. 12 caracteres)", "confirmPassword": "Confirmar senha", - "atLeast": "Pelo menos 8 caracteres, 1 minúscula e 1 numeral.", + "atLeast": "Pelo menos 12 caracteres, 1 maiúscula, 1 minúscula, 1 número e 1 caractere especial (!@#$%^&*...).", "doNotForget": "Não esqueça de salvar sua senha. Você precisará desta senha para desbloquear sua carteira.", "feeRateLabel": "Taxa ({{currency}}/byte)", "gasPriceLabel": "Preço do gás (gwei)", diff --git a/source/assets/locales/ru.json b/source/assets/locales/ru.json index e0da1dbac..ba25b08a4 100644 --- a/source/assets/locales/ru.json +++ b/source/assets/locales/ru.json @@ -217,9 +217,9 @@ "addressType": "Тип адреса" }, "components": { - "newPassword": "Новый пароль (мин. 8 символов)", + "newPassword": "Новый пароль (мин. 12 символов)", "confirmPassword": "Подтвердите пароль", - "atLeast": "Минимум 8 символов, 1 строчная буква и 1 цифра.", + "atLeast": "Минимум 12 символов, 1 заглавная буква, 1 строчная буква, 1 цифра и 1 специальный символ (!@#$%^&*...).", "doNotForget": "Не забудьте сохранить ваш пароль. Он понадобится для разблокировки кошелька.", "feeRateLabel": "Размер комиссии ({{currency}}/байт)", "gasPriceLabel": "Цена газа (gwei)", diff --git a/source/assets/locales/zh.json b/source/assets/locales/zh.json index 1a09a9a90..df4626b90 100644 --- a/source/assets/locales/zh.json +++ b/source/assets/locales/zh.json @@ -217,9 +217,9 @@ "addressType": "地址类型" }, "components": { - "newPassword": "新密码(至少8个字符)", + "newPassword": "新密码(至少12个字符)", "confirmPassword": "确认密码", - "atLeast": "至少8个字符,1个小写字母和1个数字。", + "atLeast": "至少12个字符,1个大写字母,1个小写字母,1个数字和1个特殊字符(!@#$%^&*...)。", "doNotForget": "不要忘记保存您的密码。您需要此密码来解锁您的钱包。", "feeRateLabel": "费率 ({{currency}}/字节)", "gasPriceLabel": "燃气价格 (gwei)", diff --git a/source/components/PasswordForm/PasswordForm.tsx b/source/components/PasswordForm/PasswordForm.tsx index d5a74600c..966374705 100644 --- a/source/components/PasswordForm/PasswordForm.tsx +++ b/source/components/PasswordForm/PasswordForm.tsx @@ -75,7 +75,9 @@ export const PasswordForm: React.FC = ({ onSubmit }) => { message: '', }, { - pattern: /^(?=.*[a-z])(?=.*[0-9])(?=.{8,})/, + // Strong password requirements: 12+ chars, uppercase, lowercase, number, special char + pattern: + /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{12,}$/, message: '', }, ]} From d5cacac9edf4d6c243958a3a181811115394d00b Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 9 Dec 2025 11:08:47 -0300 Subject: [PATCH 4/6] [Vulnerability 005] - fix: solve Password Rate Limiting Bypass --- .../Background/controllers/MainController.ts | 245 +++++++++++++++--- 1 file changed, 211 insertions(+), 34 deletions(-) diff --git a/source/scripts/Background/controllers/MainController.ts b/source/scripts/Background/controllers/MainController.ts index 0e151d484..8c873eedd 100644 --- a/source/scripts/Background/controllers/MainController.ts +++ b/source/scripts/Background/controllers/MainController.ts @@ -550,11 +550,13 @@ class MainController { } >(); - // Rate limiting for failed unlock attempts + // Rate limiting for failed unlock attempts (persisted to storage) private failedUnlockAttempts = 0; private lastFailedUnlockTime = 0; private readonly MAX_FAILED_ATTEMPTS = 5; private readonly LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds + private readonly RATE_LIMIT_STORAGE_KEY = 'pali_rate_limit_state'; + private rateLimitInitialized = false; private readonly UTXO_PRICE_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes cache for price data @@ -1198,8 +1200,27 @@ class MainController { return this.getActiveKeyring().getBip32Path(id, isChangeAddress); } - public getSeed(pwd: string) { - return this.getActiveKeyring().getSeed(pwd); + public async getSeed(pwd: string) { + // Check rate limiting before password validation + const remainingLockout = await this.checkRateLimit(); + if (remainingLockout > 0) { + throw new Error( + `Too many failed attempts. Please wait ${remainingLockout} seconds before trying again.` + ); + } + + try { + const seed = await this.getActiveKeyring().getSeed(pwd); + // Reset rate limit on success + await this.resetRateLimit(); + return seed; + } catch (error) { + // Record failed attempt if it's a password error + if (error.message === 'Invalid password') { + await this.recordFailedAttempt(); + } + throw error; + } } public async getPrivateKeyByAccountId( @@ -1207,11 +1228,30 @@ class MainController { accountType: any, pwd: string ) { - return await this.getActiveKeyring().getPrivateKeyByAccountId( - id, - accountType, - pwd - ); + // Check rate limiting before password validation + const remainingLockout = await this.checkRateLimit(); + if (remainingLockout > 0) { + throw new Error( + `Too many failed attempts. Please wait ${remainingLockout} seconds before trying again.` + ); + } + + try { + const privateKey = await this.getActiveKeyring().getPrivateKeyByAccountId( + id, + accountType, + pwd + ); + // Reset rate limit on success + await this.resetRateLimit(); + return privateKey; + } catch (error) { + // Record failed attempt if it's a password error + if (error.message === 'Invalid password') { + await this.recordFailedAttempt(); + } + throw error; + } } public getActiveAccount() { @@ -1226,21 +1266,150 @@ class MainController { return await this.getActiveKeyring().forgetMainWallet(pwd); } - public async unlock(pwd: string) { + /** + * Get remaining lockout time in seconds (for UI display) + * Returns 0 if not locked out + */ + public async getRemainingLockoutTime(): Promise { + return await this.checkRateLimit(); + } + + // ============================================= + // Rate Limiting Persistence Methods + // ============================================= + + /** + * Load rate limiting state from persistent storage + * This ensures rate limits survive browser restarts + */ + private async loadRateLimitState(): Promise { + if (this.rateLimitInitialized) return; + + try { + const stored = await chromeStorage.getItem(this.RATE_LIMIT_STORAGE_KEY); + if (stored && typeof stored === 'object') { + const { failedAttempts, lastFailedTime } = stored as { + failedAttempts: number; + lastFailedTime: number; + }; + this.failedUnlockAttempts = failedAttempts || 0; + this.lastFailedUnlockTime = lastFailedTime || 0; + + // Check if lockout has expired and reset if so + const now = Date.now(); + if (this.failedUnlockAttempts >= this.MAX_FAILED_ATTEMPTS) { + const timeSinceLastFailed = now - this.lastFailedUnlockTime; + if (timeSinceLastFailed >= this.LOCKOUT_DURATION) { + // Reset expired lockout + this.failedUnlockAttempts = 0; + this.lastFailedUnlockTime = 0; + await this.saveRateLimitState(); + } + } + } + this.rateLimitInitialized = true; + } catch (error) { + console.warn('[MainController] Failed to load rate limit state:', error); + this.rateLimitInitialized = true; + } + } + + /** + * Save rate limiting state to persistent storage + */ + private async saveRateLimitState(): Promise { + try { + await chromeStorage.setItem(this.RATE_LIMIT_STORAGE_KEY, { + failedAttempts: this.failedUnlockAttempts, + lastFailedTime: this.lastFailedUnlockTime, + }); + } catch (error) { + console.warn('[MainController] Failed to save rate limit state:', error); + } + } + + /** + * Check if currently locked out due to too many failed attempts + * Returns remaining lockout time in seconds, or 0 if not locked out + */ + private async checkRateLimit(): Promise { + await this.loadRateLimitState(); + + if (this.failedUnlockAttempts >= this.MAX_FAILED_ATTEMPTS) { + const now = Date.now(); + const timeSinceLastFailed = now - this.lastFailedUnlockTime; + if (timeSinceLastFailed < this.LOCKOUT_DURATION) { + return Math.ceil((this.LOCKOUT_DURATION - timeSinceLastFailed) / 1000); + } else { + // Reset after lockout period + this.failedUnlockAttempts = 0; + this.lastFailedUnlockTime = 0; + await this.saveRateLimitState(); + } + } + return 0; + } + + /** + * Record a failed password attempt + */ + private async recordFailedAttempt(): Promise { + this.failedUnlockAttempts++; + this.lastFailedUnlockTime = Date.now(); + await this.saveRateLimitState(); + } + + /** + * Reset rate limiting on successful authentication + */ + private async resetRateLimit(): Promise { + this.failedUnlockAttempts = 0; + this.lastFailedUnlockTime = 0; + await this.saveRateLimitState(); + } + + // ============================================= + // End Rate Limiting Methods + // ============================================= + + public async unlock(pwd: string, skipRateLimit = false) { console.log('[MainController] Attempting to unlock wallet'); + + // Check rate limiting (skip for internal dry-run checks) + if (!skipRateLimit) { + const remainingLockout = await this.checkRateLimit(); + if (remainingLockout > 0) { + throw new Error( + `Too many failed attempts. Please wait ${remainingLockout} seconds before trying again.` + ); + } + } + try { const keyring = this.getActiveKeyring(); const result = await keyring.unlock(pwd); if (result.canLogin) { console.log('[MainController] Wallet unlocked successfully'); + // Reset rate limit on successful login + if (!skipRateLimit) { + await this.resetRateLimit(); + } } else { console.warn('[MainController] Wallet unlock returned canLogin=false'); + // Record failed attempt + if (!skipRateLimit) { + await this.recordFailedAttempt(); + } } return result; } catch (error) { console.error('[MainController] Error during wallet unlock:', error); + // Record failed attempt for password errors + if (!skipRateLimit) { + await this.recordFailedAttempt(); + } throw error; } } @@ -1617,9 +1786,28 @@ class MainController { } public async forgetWallet(pwd: string) { - // FIRST: Validate password - throws if wrong password or wallet locked - // This prevents unnecessary cleanup if validation fails - await this.forgetMainWallet(pwd); + // Check rate limiting before password validation + const remainingLockout = await this.checkRateLimit(); + if (remainingLockout > 0) { + throw new Error( + `Too many failed attempts. Please wait ${remainingLockout} seconds before trying again.` + ); + } + + try { + // FIRST: Validate password - throws if wrong password or wallet locked + // This prevents unnecessary cleanup if validation fails + await this.forgetMainWallet(pwd); + // Reset rate limit on success + await this.resetRateLimit(); + } catch (error) { + // Record failed attempt if it's a password error + if (error.message === 'Invalid password') { + await this.recordFailedAttempt(); + } + throw error; + } + // Now proceed with cleanup since password is valid await this.resetWalletState({ resetNetworks: true }); @@ -1639,21 +1827,12 @@ class MainController { } public async unlockFromController(pwd: string): Promise { - // Check rate limiting for failed unlock attempts - const now = Date.now(); - if (this.failedUnlockAttempts >= this.MAX_FAILED_ATTEMPTS) { - const timeSinceLastFailed = now - this.lastFailedUnlockTime; - if (timeSinceLastFailed < this.LOCKOUT_DURATION) { - const remainingTime = Math.ceil( - (this.LOCKOUT_DURATION - timeSinceLastFailed) / 1000 - ); - throw new Error( - `Too many failed attempts. Please wait ${remainingTime} seconds before trying again.` - ); - } else { - // Reset after lockout period - this.failedUnlockAttempts = 0; - } + // Check rate limiting for failed unlock attempts (uses persisted state) + const remainingLockout = await this.checkRateLimit(); + if (remainingLockout > 0) { + throw new Error( + `Too many failed attempts. Please wait ${remainingLockout} seconds before trying again.` + ); } // Ensure clean network state during login @@ -1698,7 +1877,7 @@ class MainController { this.lockAllKeyrings(); const { canLogin, needsAccountCreation, needsXprvMigration } = - await this.unlock(pwd); + await this.unlock(pwd, true); // Skip rate limiting - handled above if (!canLogin) { console.error('[MainController] Unlock failed - invalid password'); @@ -1707,9 +1886,8 @@ class MainController { console.log('[MainController] Unlock successful'); - // Reset failed attempts on successful unlock - this.failedUnlockAttempts = 0; - this.lastFailedUnlockTime = 0; + // Reset failed attempts on successful unlock (persisted) + await this.resetRateLimit(); // Check if this is a migration from old vault format that needs account creation if (needsAccountCreation) { @@ -1982,10 +2160,9 @@ class MainController { } catch (error) { console.error('[MainController] Unlock error:', error); - // Increment failed attempts if it's a password error + // Record failed attempt if it's a password error (persisted) if (error.message === 'Invalid password') { - this.failedUnlockAttempts++; - this.lastFailedUnlockTime = Date.now(); + await this.recordFailedAttempt(); } throw error; From 4e31e112bbf4edbe14be4aeaf02411b66cf94dd1 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 9 Dec 2025 11:29:25 -0300 Subject: [PATCH 5/6] [Vulnerability 005] - fix: solve Cached ENS Lookup in UI --- .../Transactions/EVM/EvmDetailsEnhanced.tsx | 10 +++-- source/pages/Send/Confirm.tsx | 10 +++-- source/pages/Send/SendEth.tsx | 12 ++--- .../Send/components/TransactionDetails.tsx | 4 +- source/state/vault/selectors.ts | 44 ++++++++++++++++--- source/state/vaultGlobal/index.ts | 42 +++++++++++++++++- 6 files changed, 101 insertions(+), 21 deletions(-) diff --git a/source/pages/Home/Panel/components/Transactions/EVM/EvmDetailsEnhanced.tsx b/source/pages/Home/Panel/components/Transactions/EVM/EvmDetailsEnhanced.tsx index 3d05134a3..93f09a707 100644 --- a/source/pages/Home/Panel/components/Transactions/EVM/EvmDetailsEnhanced.tsx +++ b/source/pages/Home/Panel/components/Transactions/EVM/EvmDetailsEnhanced.tsx @@ -14,7 +14,10 @@ import { useTransactionsListConfig, useUtils } from 'hooks/index'; import { useController } from 'hooks/useController'; import type { IEvmTransactionResponse } from 'scripts/Background/controllers/transactions/types'; import { RootState } from 'state/store'; -import { selectActiveAccount } from 'state/vault/selectors'; +import { + selectActiveAccount, + selectValidEnsCache, +} from 'state/vault/selectors'; import { IDecodedTx } from 'types/transactions'; import { formatMethodName } from 'utils/commonMethodSignatures'; import { camelCaseToText } from 'utils/index'; @@ -37,9 +40,8 @@ export const EvmTransactionDetailsEnhanced = ({ tx: IEvmTransactionResponse; }) => { const { controllerEmitter } = useController(); - const ensCache = useSelector( - (state: RootState) => state.vaultGlobal.ensCache - ); + // Use valid (non-expired) ENS cache for security + const ensCache = useSelector(selectValidEnsCache); const { activeNetwork: { chainId, currency, apiUrl }, } = useSelector((state: RootState) => state.vault); diff --git a/source/pages/Send/Confirm.tsx b/source/pages/Send/Confirm.tsx index f42da34df..cedab8292 100644 --- a/source/pages/Send/Confirm.tsx +++ b/source/pages/Send/Confirm.tsx @@ -25,7 +25,10 @@ import { useUtils, usePrice } from 'hooks/index'; import { useController } from 'hooks/useController'; import { useEIP1559 } from 'hooks/useEIP1559'; import { RootState } from 'state/store'; -import { selectEnsNameToAddress } from 'state/vault/selectors'; +import { + selectEnsNameToAddress, + selectValidEnsCache, +} from 'state/vault/selectors'; import { INetworkType } from 'types/network'; import { handleTransactionError } from 'utils/errorHandling'; import { formatGweiValue } from 'utils/formatSyscoinValue'; @@ -61,9 +64,8 @@ export const SendConfirm = () => { ); const { fiat } = useSelector((state: RootState) => state.price); const activeAccount = accounts[activeAccountMeta.type][activeAccountMeta.id]; - const ensCache = useSelector( - (state: RootState) => state.vaultGlobal.ensCache - ); + // Use valid (non-expired) ENS cache for security + const ensCache = useSelector(selectValidEnsCache); // when using the default routing, state will have the tx data // when using createPopup (DApps), the data comes from route params const location = useLocation(); diff --git a/source/pages/Send/SendEth.tsx b/source/pages/Send/SendEth.tsx index c24b7261a..e85b92ed6 100644 --- a/source/pages/Send/SendEth.tsx +++ b/source/pages/Send/SendEth.tsx @@ -21,8 +21,11 @@ import { useUtils } from 'hooks/index'; import { useAdjustedExplorer } from 'hooks/useAdjustedExplorer'; import { useController } from 'hooks/useController'; import { RootState } from 'state/store'; -import { selectEnsNameToAddress } from 'state/vault/selectors'; -import { selectActiveAccountWithAssets } from 'state/vault/selectors'; +import { + selectEnsNameToAddress, + selectActiveAccountWithAssets, + selectValidEnsCache, +} from 'state/vault/selectors'; import { ITokenEthProps } from 'types/tokens'; import { getAssetBalance, @@ -106,9 +109,8 @@ export const SendEth = () => { >([]); const accounts = useSelector((state: RootState) => state.vault.accounts); - const ensCache = useSelector( - (state: RootState) => state.vaultGlobal.ensCache - ); + // Use valid (non-expired) ENS cache for security + const ensCache = useSelector(selectValidEnsCache); const ensNameToAddress = useSelector(selectEnsNameToAddress); const vaultActiveAccount = useSelector( (state: RootState) => state.vault.activeAccount diff --git a/source/pages/Send/components/TransactionDetails.tsx b/source/pages/Send/components/TransactionDetails.tsx index 7819342b0..6dfcf36b6 100644 --- a/source/pages/Send/components/TransactionDetails.tsx +++ b/source/pages/Send/components/TransactionDetails.tsx @@ -12,6 +12,7 @@ import { Tooltip } from 'components/Tooltip'; import { useController } from 'hooks/useController'; import { useUtils } from 'hooks/useUtils'; import { RootState } from 'state/store'; +import { selectValidEnsCache } from 'state/vault/selectors'; import { IBlacklistCheckResult } from 'types/security'; import { ICustomFeeParams, @@ -64,7 +65,8 @@ export const TransactionDetailsComponent = ( (state: RootState) => state.vault.activeNetwork ); // Prefer rendering ENS name when available in cache for destination - const ensCache = useReduxSelector((s: RootState) => s.vaultGlobal.ensCache); + // Use valid (non-expired) ENS cache for security + const ensCache = useReduxSelector(selectValidEnsCache); // Helper function to get appropriate copy message based on field type const getCopyMessage = (fieldType: 'address' | 'hash' | 'other') => { diff --git a/source/state/vault/selectors.ts b/source/state/vault/selectors.ts index dd5b1e68a..2f1c07878 100644 --- a/source/state/vault/selectors.ts +++ b/source/state/vault/selectors.ts @@ -1,6 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from 'state/store'; +import { ENS_CACHE_TTL_MS } from 'state/vaultGlobal'; import { IAccountAssets, IAccountTransactions } from './types'; @@ -85,17 +86,50 @@ export const selectActiveAccountAndVaultData = createSelector( (account, assets, vaultData) => ({ account, assets, ...vaultData }) ); -// ENS selectors +// ENS selectors with TTL expiration support export const selectEnsCache = (state: RootState) => state.vaultGlobal.ensCache; -// Derived map: nameLower -> addressLower, built once and memoized -export const selectEnsNameToAddress = createSelector( +/** + * Helper to check if an ENS cache entry is still valid (not expired) + * @param entry - The cache entry with timestamp + * @param now - Current timestamp (defaults to Date.now()) + * @returns true if the entry is still valid + */ +export const isEnsCacheEntryValid = ( + entry: { name: string; timestamp: number }, + now: number = Date.now() +): boolean => now - entry.timestamp < ENS_CACHE_TTL_MS; + +/** + * Selector that returns only non-expired ENS cache entries + * This ensures stale ENS lookups are not used for security + */ +export const selectValidEnsCache = createSelector( [selectEnsCache], (ensCache) => { + if (!ensCache) return {}; + const now = Date.now(); + const validCache: typeof ensCache = {}; + + for (const [addrLower, entry] of Object.entries(ensCache)) { + if (isEnsCacheEntryValid(entry, now)) { + validCache[addrLower] = entry; + } + } + + return validCache; + } +); + +// Derived map: nameLower -> addressLower, built once and memoized +// Only includes non-expired entries for security +export const selectEnsNameToAddress = createSelector( + [selectValidEnsCache], + (validEnsCache) => { const map: Record = {}; - if (!ensCache) return map; + if (!validEnsCache) return map; try { - for (const [addrLower, v] of Object.entries(ensCache as any)) { + for (const [addrLower, v] of Object.entries(validEnsCache as any)) { const nameLower = String((v as any)?.name || '').toLowerCase(); if (nameLower) map[nameLower] = addrLower; } diff --git a/source/state/vaultGlobal/index.ts b/source/state/vaultGlobal/index.ts index 5b51cb0e6..9ae412499 100644 --- a/source/state/vaultGlobal/index.ts +++ b/source/state/vaultGlobal/index.ts @@ -4,6 +4,10 @@ import { IGlobalState } from '../vault/types'; import { INetwork, INetworkType } from 'types/network'; import { PALI_NETWORKS_STATE } from 'utils/constants'; +// ENS cache TTL: 5 minutes (300,000 ms) +// ENS names can change, so we don't cache them indefinitely for security +export const ENS_CACHE_TTL_MS = 5 * 60 * 1000; + const initialState: IGlobalState = { activeSlip44: null, advancedSettings: { @@ -122,10 +126,42 @@ const vaultGlobalSlice = createSlice({ ) { if (!state.ensCache) state.ensCache = {}; const addressLower = action.payload.address.toLowerCase(); - state.ensCache[addressLower] = { + const now = Date.now(); + + // Clean up expired entries while adding new one (opportunistic cleanup) + const cleanedCache: typeof state.ensCache = {}; + for (const [addr, entry] of Object.entries(state.ensCache)) { + if (now - entry.timestamp < ENS_CACHE_TTL_MS) { + cleanedCache[addr] = entry; + } + } + + // Add the new entry + cleanedCache[addressLower] = { name: action.payload.name, - timestamp: Date.now(), + timestamp: now, }; + + state.ensCache = cleanedCache; + }, + clearExpiredEnsCache(state: IGlobalState) { + // Clear all expired ENS cache entries + if (!state.ensCache) return; + + const now = Date.now(); + const cleanedCache: typeof state.ensCache = {}; + + for (const [addr, entry] of Object.entries(state.ensCache)) { + if (now - entry.timestamp < ENS_CACHE_TTL_MS) { + cleanedCache[addr] = entry; + } + } + + state.ensCache = cleanedCache; + }, + clearAllEnsCache(state: IGlobalState) { + // Clear the entire ENS cache (useful for security-sensitive operations) + state.ensCache = {}; }, setIsSwitchingAccount(state: IGlobalState, action: PayloadAction) { state.isSwitchingAccount = action.payload; @@ -332,6 +368,8 @@ export const { resetNetworkQualityForNewNetwork, setPostNetworkSwitchLoading, setEnsName, + clearExpiredEnsCache, + clearAllEnsCache, } = vaultGlobalSlice.actions; export default vaultGlobalSlice.reducer; From 8e968bb3a04b6823b5ca5f13f4ec32bad317df10 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 9 Dec 2025 11:43:59 -0300 Subject: [PATCH 6/6] [Vulnerability 006] - fix: solve Vulnerable Dependencies --- package.json | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index d04dc7a52..e23afd7eb 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@types/jest": "^29.5.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "antd": "^4.24.15", - "axios": "^1.7.9", + "axios": "^1.12.0", "bignumber.js": "^9.1.2", "currency-symbol-map": "^5.1.0", "currency.js": "^2.0.4", @@ -113,7 +113,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.25.9", "@babel/plugin-transform-runtime": "^7.25.9", "@babel/preset-env": "^7.26.0", - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.55.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -172,5 +172,16 @@ "url": "https://github.com/syscoin/pali_wallet/issues" }, "homepage": "https://github.com/syscoin/pali_wallet#readme", - "author": "Syscoin" + "author": "Syscoin", + "resolutions": { + "node-fetch": "^2.7.0", + "axios": "^1.12.0", + "node-forge": "^1.3.2", + "valibot": "^1.2.0", + "glob": "^10.5.0", + "cookie": "^0.7.0", + "tmp": "^0.2.4", + "jws": "^4.0.1", + "js-yaml": "^4.1.1" + } }