From d702f09dc2c757ea48525ddbfcb171b9d08a9717 Mon Sep 17 00:00:00 2001 From: adenueltech Date: Sat, 17 Oct 2026 21:18:03 +0100 Subject: [PATCH] feat: wallet security best practices and warnings (#890) - Add src/lib/walletSecurity.ts: security tips, secret key / recovery phrase detection, sliding-window rate limiter, local security event log and suspicious-activity rules - Add WalletSecurityCenter (mounted in layout): security tips after login, warning when a secret key or recovery phrase is typed/pasted, warning on unencrypted (non-HTTPS) connections, suspicious activity alerts - Rate limit wallet connections (5/min) and signing requests (10/min); log failed connects, rejected signatures and account switches - Session timeout: 60s warning with "Stay signed in", clear cached wallet session on lock, configurable timeout in settings - Optional biometric unlock (WebAuthn platform authenticator) - Wallet security section on /settings/security with timeout, biometric toggle, tips and recent security activity Closes #890 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/__tests__/SessionLock.test.tsx | 49 ++++ src/__tests__/walletSecurity.test.ts | 152 +++++++++++ src/app/layout.tsx | 2 + src/app/settings/security/page.tsx | 3 + .../security/WalletSecurityCenter.tsx | 246 ++++++++++++++++++ .../security/WalletSecuritySettings.tsx | 192 ++++++++++++++ src/contexts/SessionLockContext.tsx | 137 ++++++++-- src/hooks/useWallet.ts | 8 + src/lib/biometric.ts | 107 ++++++++ src/lib/freighter.ts | 30 ++- src/lib/walletSecurity.ts | 241 +++++++++++++++++ 11 files changed, 1142 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/walletSecurity.test.ts create mode 100644 src/components/security/WalletSecurityCenter.tsx create mode 100644 src/components/security/WalletSecuritySettings.tsx create mode 100644 src/lib/biometric.ts create mode 100644 src/lib/walletSecurity.ts diff --git a/src/__tests__/SessionLock.test.tsx b/src/__tests__/SessionLock.test.tsx index f06db63a..27fe5376 100644 --- a/src/__tests__/SessionLock.test.tsx +++ b/src/__tests__/SessionLock.test.tsx @@ -133,3 +133,52 @@ test("lock overlay shows Resume button", () => { expect(screen.getByText("Session Locked")).toBeInTheDocument(); expect(screen.getAllByText("Resume").length).toBeGreaterThanOrEqual(1); }); + +test("warns during the last minute before locking", () => { + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(14 * 60 * 1000 + 30_000); + }); + + expect(screen.getByText(/Your session will lock in \d+s/)).toBeInTheDocument(); + fireEvent.click(screen.getByText("Stay signed in")); + expect(screen.queryByText(/Your session will lock in/)).not.toBeInTheDocument(); + expect(screen.getByTestId("locked").textContent).toBe("false"); +}); + +test("locking clears the cached wallet session", () => { + sessionStorage.setItem("stellarsplit_wallet", JSON.stringify({ address: "GABC", walletType: "freighter" })); + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(16 * 60 * 1000); + }); + + expect(screen.getByTestId("locked").textContent).toBe("true"); + expect(sessionStorage.getItem("stellarsplit_wallet")).toBeNull(); +}); + +test("requires biometrics to unlock when enabled", () => { + localStorage.setItem("stellarsplit_biometric_credential", "abc"); + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(16 * 60 * 1000); + }); + + expect(screen.getByText("Unlock with biometrics")).toBeInTheDocument(); + expect(screen.getByText("Disconnect wallet")).toBeInTheDocument(); +}); diff --git a/src/__tests__/walletSecurity.test.ts b/src/__tests__/walletSecurity.test.ts new file mode 100644 index 00000000..0880b316 --- /dev/null +++ b/src/__tests__/walletSecurity.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { StrKey } from "@stellar/stellar-sdk"; +import { + SECURITY_EVENTS_KEY, + SECURITY_EVENT_NAME, + SECURITY_TIPS, + RateLimitError, + clearSecurityEvents, + containsSecretKey, + createRateLimiter, + detectSecretMaterial, + detectWalletAnomalies, + enforceRateLimit, + isConnectionEncrypted, + looksLikeRecoveryPhrase, + readSecurityEvents, + recordSecurityEvent, + type SecurityEvent, +} from "@/lib/walletSecurity"; + +const SECRET = StrKey.encodeEd25519SecretSeed(Buffer.alloc(32, 9)); +const PUBLIC = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 9)); +const PHRASE = "abandon ability able about above absent absorb abstract absurd abuse access accident"; + +describe("wallet security (#890)", () => { + beforeEach(() => localStorage.clear()); + + it("has security tips with titles and bodies", () => { + expect(SECURITY_TIPS.length).toBeGreaterThanOrEqual(4); + SECURITY_TIPS.forEach((t) => { + expect(t.title).toBeTruthy(); + expect(t.body).toBeTruthy(); + }); + }); + + describe("secret detection", () => { + it("finds a valid secret seed, alone or inside other text", () => { + expect(containsSecretKey(SECRET)).toBe(true); + expect(containsSecretKey(`my key is ${SECRET} ok`)).toBe(true); + }); + + it("ignores public keys and look-alikes with a bad checksum", () => { + expect(containsSecretKey(PUBLIC)).toBe(false); + expect(containsSecretKey("S" + "A".repeat(55))).toBe(false); + }); + + it("recognises a 12-word recovery phrase but not normal sentences", () => { + expect(looksLikeRecoveryPhrase(PHRASE)).toBe(true); + expect(looksLikeRecoveryPhrase("Thanks for the design work on the Q3 landing page, invoice attached")).toBe(false); + expect(looksLikeRecoveryPhrase("one two three")).toBe(false); + }); + + it("classifies the kind of secret", () => { + expect(detectSecretMaterial(SECRET)).toBe("secret_key"); + expect(detectSecretMaterial(PHRASE)).toBe("recovery_phrase"); + expect(detectSecretMaterial(PUBLIC)).toBeNull(); + expect(detectSecretMaterial("")).toBeNull(); + }); + }); + + it("treats https and localhost as encrypted", () => { + expect(isConnectionEncrypted({ protocol: "https:", hostname: "app.example" })).toBe(true); + expect(isConnectionEncrypted({ protocol: "http:", hostname: "localhost" })).toBe(true); + expect(isConnectionEncrypted({ protocol: "http:", hostname: "app.example" })).toBe(false); + expect(isConnectionEncrypted({ protocol: "https:", hostname: "x" }, false)).toBe(false); + }); + + describe("rate limiting", () => { + it("allows up to the limit inside the window, then blocks with a retry time", () => { + const limiter = createRateLimiter({ limit: 3, windowMs: 1000 }); + expect(limiter.attempt("k", 0).allowed).toBe(true); + expect(limiter.attempt("k", 100).allowed).toBe(true); + expect(limiter.attempt("k", 200).remaining).toBe(0); + const blocked = limiter.attempt("k", 300); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterMs).toBe(700); + }); + + it("frees slots as the window slides and keeps keys independent", () => { + const limiter = createRateLimiter({ limit: 1, windowMs: 1000 }); + expect(limiter.attempt("a", 0).allowed).toBe(true); + expect(limiter.attempt("b", 0).allowed).toBe(true); + expect(limiter.attempt("a", 500).allowed).toBe(false); + expect(limiter.attempt("a", 1000).allowed).toBe(true); + }); + + it("enforceRateLimit throws a RateLimitError and logs the event", () => { + const limiter = createRateLimiter({ limit: 1, windowMs: 60_000 }); + enforceRateLimit(limiter, "x", "test"); + expect(() => enforceRateLimit(limiter, "x", "test")).toThrow(RateLimitError); + expect(readSecurityEvents().map((e) => e.type)).toContain("rate_limited"); + }); + }); + + describe("security event log", () => { + it("records events, notifies listeners and can be cleared", () => { + const listener = vi.fn(); + window.addEventListener(SECURITY_EVENT_NAME, listener); + recordSecurityEvent({ type: "connect_failed", detail: "freighter" }); + window.removeEventListener(SECURITY_EVENT_NAME, listener); + + expect(listener).toHaveBeenCalledTimes(1); + expect(readSecurityEvents()[0]).toMatchObject({ type: "connect_failed", detail: "freighter" }); + clearSecurityEvents(); + expect(readSecurityEvents()).toEqual([]); + }); + + it("keeps only the latest 100 events and survives corrupt storage", () => { + for (let i = 0; i < 120; i++) recordSecurityEvent({ type: "session_locked", timestamp: i }); + const events = readSecurityEvents(); + expect(events).toHaveLength(100); + expect(events[0].timestamp).toBe(20); + + localStorage.setItem(SECURITY_EVENTS_KEY, "not json"); + expect(readSecurityEvents()).toEqual([]); + }); + }); + + describe("suspicious activity detection", () => { + const now = 1_000_000_000; + const ev = (type: SecurityEvent["type"], agoMs = 0): SecurityEvent => ({ type, timestamp: now - agoMs }); + + it("is quiet for normal activity", () => { + expect(detectWalletAnomalies([ev("connect_failed"), ev("session_locked")], now)).toEqual([]); + }); + + it("flags repeated failures and rejections", () => { + const ids = detectWalletAnomalies( + [ev("connect_failed"), ev("connect_failed"), ev("connect_failed"), ev("sign_rejected"), ev("sign_rejected"), ev("sign_rejected")], + now + ).map((a) => a.id); + expect(ids).toEqual(expect.arrayContaining(["connect_failed", "sign_rejected"])); + }); + + it("treats an exposed secret as high severity", () => { + const [alert] = detectWalletAnomalies([ev("secret_detected")], now); + expect(alert).toMatchObject({ id: "secret_detected", severity: "high" }); + }); + + it("flags account switching and rate limiting", () => { + const ids = detectWalletAnomalies([ev("account_changed"), ev("account_changed"), ev("rate_limited")], now).map( + (a) => a.id + ); + expect(ids).toEqual(expect.arrayContaining(["account_changed", "rate_limited"])); + }); + + it("ignores events older than 10 minutes", () => { + const old = 11 * 60_000; + expect(detectWalletAnomalies([ev("connect_failed", old), ev("connect_failed", old), ev("connect_failed", old)], now)).toEqual([]); + }); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4714ebf9..633ba9b7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,6 +22,7 @@ import { UserPreferencesProvider } from "@/context/UserPreferencesContext"; import { FiatRateProvider } from "@/hooks/useFiatRate"; import { ShortcutRegistryProvider } from "@/context/ShortcutRegistry"; import OfflineBanner from "@/components/OfflineBanner"; +import WalletSecurityCenter from "@/components/security/WalletSecurityCenter"; import { getNonce } from "@/lib/csp"; import TransitionProvider from "@/components/layout/TransitionProvider"; @@ -135,6 +136,7 @@ export default function RootLayout({ + {children} diff --git a/src/app/settings/security/page.tsx b/src/app/settings/security/page.tsx index 011d6b5b..9191473f 100644 --- a/src/app/settings/security/page.tsx +++ b/src/app/settings/security/page.tsx @@ -4,6 +4,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/apiClient'; +import WalletSecuritySettings from '@/components/security/WalletSecuritySettings'; interface SecuritySettingsResponse { mfaEnabled: boolean; @@ -157,6 +158,8 @@ export default function SecuritySettingsPage() { ← Back to settings + +
diff --git a/src/components/security/WalletSecurityCenter.tsx b/src/components/security/WalletSecurityCenter.tsx new file mode 100644 index 00000000..0e70d249 --- /dev/null +++ b/src/components/security/WalletSecurityCenter.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import FocusTrap from "@/components/FocusTrap"; +import { useWalletContext } from "@/contexts/WalletContext"; +import { + SECURITY_EVENT_NAME, + SECURITY_TIPS, + detectSecretMaterial, + detectWalletAnomalies, + isConnectionEncrypted, + readSecurityEvents, + recordSecurityEvent, + type SecretKind, + type WalletSecurityAlert, +} from "@/lib/walletSecurity"; + +export const TIPS_DISMISSED_KEY = "stellarsplit_security_tips_dismissed"; +const TIPS_SHOWN_SESSION_KEY = "stellarsplit_security_tips_shown"; + +function storageGet(storage: () => Storage, key: string): string | null { + try { + return storage().getItem(key); + } catch { + return null; + } +} + +function storageSet(storage: () => Storage, key: string, value: string) { + try { + storage().setItem(key, value); + } catch { + // ignore + } +} + +/** + * WalletSecurityCenter — app-wide wallet safety layer: + * - shows security tips once per session after a wallet connects, + * - warns when a secret key or recovery phrase is typed or pasted anywhere, + * - warns when the page is not served over an encrypted connection, + * - surfaces suspicious wallet activity detected from the local security log. + */ +export default function WalletSecurityCenter() { + const { address } = useWalletContext(); + const previousAddress = useRef(null); + const [showTips, setShowTips] = useState(false); + const [dontShowAgain, setDontShowAgain] = useState(false); + const [secretWarning, setSecretWarning] = useState(null); + const [insecure, setInsecure] = useState(false); + const [alerts, setAlerts] = useState([]); + const [dismissedAlerts, setDismissedAlerts] = useState>(new Set()); + + // Security tips on login, and logging of account switches. + useEffect(() => { + const prev = previousAddress.current; + previousAddress.current = address; + if (!address) return; + if (prev && prev !== address) { + recordSecurityEvent({ type: "account_changed" }); + } + if ( + !prev && + storageGet(() => localStorage, TIPS_DISMISSED_KEY) !== "1" && + storageGet(() => sessionStorage, TIPS_SHOWN_SESSION_KEY) !== "1" + ) { + storageSet(() => sessionStorage, TIPS_SHOWN_SESSION_KEY, "1"); + setShowTips(true); + } + }, [address]); + + // Warn about unencrypted connections. + useEffect(() => { + setInsecure(!isConnectionEncrypted(window.location, window.isSecureContext)); + }, []); + + // Detect secret keys / recovery phrases typed or pasted into any field. + useEffect(() => { + const flagged = new WeakMap(); + + const check = (text: string, target: EventTarget | null) => { + const kind = detectSecretMaterial(text); + if (!kind || !target) return; + if (flagged.get(target) === kind) return; + flagged.set(target, kind); + recordSecurityEvent({ type: "secret_detected", detail: kind }); + setSecretWarning(kind); + }; + + const onPaste = (e: ClipboardEvent) => check(e.clipboardData?.getData("text") ?? "", e.target); + const onInput = (e: Event) => { + const el = e.target; + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + if (el.type === "password") return; + check(el.value, el); + } + }; + + document.addEventListener("paste", onPaste, true); + document.addEventListener("input", onInput, true); + return () => { + document.removeEventListener("paste", onPaste, true); + document.removeEventListener("input", onInput, true); + }; + }, []); + + // Suspicious activity from the local security log. + const refreshAlerts = useCallback(() => setAlerts(detectWalletAnomalies(readSecurityEvents())), []); + useEffect(() => { + refreshAlerts(); + window.addEventListener(SECURITY_EVENT_NAME, refreshAlerts); + const id = setInterval(refreshAlerts, 60_000); + return () => { + window.removeEventListener(SECURITY_EVENT_NAME, refreshAlerts); + clearInterval(id); + }; + }, [refreshAlerts]); + + const closeTips = () => { + if (dontShowAgain) storageSet(() => localStorage, TIPS_DISMISSED_KEY, "1"); + setShowTips(false); + }; + + // The secret warning already covers the "secret_detected" alert. + const visibleAlerts = alerts.filter((a) => !dismissedAlerts.has(a.id) && a.id !== "secret_detected"); + + return ( + <> + {(insecure || visibleAlerts.length > 0) && ( +
+ {insecure && ( +
+ This connection is not encrypted. Don't connect a wallet or sign transactions + unless the address bar shows https://. +
+ )} + {visibleAlerts.map((a) => ( +
+ +

+ Suspicious activity: {a.message}{" "} + + Review + +

+ +
+ ))} +
+ )} + + {secretWarning && ( +
+ setSecretWarning(null)}> +
+

+ ⚠ That looks like your {secretWarning === "secret_key" ? "secret key" : "recovery phrase"} +

+
+

+ StellarSplit never needs your {secretWarning === "secret_key" ? "secret key" : "recovery phrase"}. + Anyone who sees it can take your funds. +

+

Delete it from this field. If you pasted it anywhere else, move your funds to a new wallet.

+
+ +
+
+
+ )} + + {showTips && ( +
+ +
e.stopPropagation()} + > +

+ 🔒 Keep your wallet safe +

+
    + {SECURITY_TIPS.map((tip) => ( +
  • +

    {tip.title}

    +

    {tip.body}

    +
  • + ))} +
+ +
+ + Security settings + + +
+
+
+
+ )} + + ); +} diff --git a/src/components/security/WalletSecuritySettings.tsx b/src/components/security/WalletSecuritySettings.tsx new file mode 100644 index 00000000..d3654e0d --- /dev/null +++ b/src/components/security/WalletSecuritySettings.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SESSION_TIMEOUT_OPTIONS, useSessionLock } from "@/contexts/SessionLockContext"; +import { + disableBiometric, + enableBiometric, + isBiometricAvailable, + isBiometricEnabled, +} from "@/lib/biometric"; +import { + SECURITY_EVENT_NAME, + SECURITY_TIPS, + clearSecurityEvents, + readSecurityEvents, + type SecurityEvent, + type SecurityEventType, +} from "@/lib/walletSecurity"; +import { TIPS_DISMISSED_KEY } from "@/components/security/WalletSecurityCenter"; + +const EVENT_LABELS: Record = { + connect_failed: "Wallet connection failed", + sign_rejected: "Signing request rejected or failed", + account_changed: "Connected account changed", + secret_detected: "Secret key or recovery phrase entered", + rate_limited: "Wallet action rate limited", + session_locked: "Session locked after inactivity", +}; + +/** + * WalletSecuritySettings — session timeout, optional biometric unlock, + * security tips and the recent security event log on /settings/security. + */ +export default function WalletSecuritySettings() { + const { timeoutMinutes, setTimeoutMinutes } = useSessionLock(); + const [biometricAvailable, setBiometricAvailable] = useState(null); + const [biometricOn, setBiometricOn] = useState(false); + const [biometricBusy, setBiometricBusy] = useState(false); + const [biometricMessage, setBiometricMessage] = useState(null); + const [events, setEvents] = useState([]); + + useEffect(() => { + isBiometricAvailable().then(setBiometricAvailable); + setBiometricOn(isBiometricEnabled()); + const refresh = () => setEvents(readSecurityEvents().slice(-20).reverse()); + refresh(); + window.addEventListener(SECURITY_EVENT_NAME, refresh); + return () => window.removeEventListener(SECURITY_EVENT_NAME, refresh); + }, []); + + const toggleBiometric = async () => { + setBiometricMessage(null); + if (biometricOn) { + disableBiometric(); + setBiometricOn(false); + setBiometricMessage("Biometric unlock turned off."); + return; + } + setBiometricBusy(true); + try { + await enableBiometric(); + setBiometricOn(true); + setBiometricMessage("Biometric unlock is on. You'll be asked to verify when your session locks."); + } catch (e) { + setBiometricMessage(e instanceof Error ? e.message : "Couldn't set up biometric unlock."); + } finally { + setBiometricBusy(false); + } + }; + + return ( +
+

Wallet security

+

Protect your session when you step away from this device.

+ +
+
+ +

+ You'll get a warning a minute before. Locking also forgets the cached wallet session, so a reload + needs a fresh connection. +

+ +
+ +
+
+ Biometric unlock + +
+

+ {biometricAvailable === false + ? "No fingerprint, face or Windows Hello sensor was found on this device." + : "Optional. Require Touch ID, Face ID, Windows Hello or fingerprint to unlock a locked session. This stays on this device."} +

+ {biometricMessage && ( +

{biometricMessage}

+ )} +
+
+ +
+
+

Security tips

+ +
+
    + {SECURITY_TIPS.map((tip) => ( +
  • + {tip.title}.{" "} + {tip.body} +
  • + ))} +
+
+ +
+
+

Recent security activity

+ {events.length > 0 && ( + + )} +
+ {events.length === 0 ? ( +

No security events recorded on this device.

+ ) : ( +
    + {events.map((e, i) => ( +
  • + {EVENT_LABELS[e.type] ?? e.type} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/contexts/SessionLockContext.tsx b/src/contexts/SessionLockContext.tsx index 00974231..05f621f2 100644 --- a/src/contexts/SessionLockContext.tsx +++ b/src/contexts/SessionLockContext.tsx @@ -1,6 +1,8 @@ "use client"; import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"; +import { isBiometricEnabled, verifyBiometric } from "@/lib/biometric"; +import { recordSecurityEvent } from "@/lib/walletSecurity"; type TimeoutOption = 5 | 15 | 30 | 60 | null; @@ -9,12 +11,20 @@ interface SessionLockContextValue { timeoutMinutes: TimeoutOption; setTimeoutMinutes: (minutes: TimeoutOption) => void; resume: () => void; + /** Seconds left before the session locks, or null when not in the warning window. */ + secondsUntilLock: number | null; } const SessionLockContext = createContext(undefined); const STORAGE_KEY = "stellarsplit_session_lock_timeout"; +/** Cached wallet connection written by `useWallet`; cleared on lock so a reload needs a fresh connect. */ +const WALLET_SESSION_KEY = "stellarsplit_wallet"; const INTERACTION_EVENTS: (keyof DocumentEventMap)[] = ["mousedown", "keydown", "touchstart"]; +const WARNING_SECONDS = 60; +const TICK_MS = 1_000; + +export const SESSION_TIMEOUT_OPTIONS: TimeoutOption[] = [5, 15, 30, 60, null]; function getStoredTimeout(): TimeoutOption { if (typeof window === "undefined") return 15; @@ -27,14 +37,27 @@ function getStoredTimeout(): TimeoutOption { return 15; } +/** + * Locks the app after a period of inactivity. Shows a countdown warning in + * the last minute, clears the cached wallet session when it locks, and — when + * the user has enabled it — requires a biometric check to unlock. + */ export function SessionLockProvider({ children }: { children: React.ReactNode }) { const [locked, setLocked] = useState(false); const [timeoutMinutes, setTimeoutMinutesState] = useState(getStoredTimeout); + const [secondsUntilLock, setSecondsUntilLock] = useState(null); + const [unlocking, setUnlocking] = useState(false); + const [unlockError, setUnlockError] = useState(null); + const [biometricRequired, setBiometricRequired] = useState(false); const lastActivityRef = useRef(Date.now()); + const lockedRef = useRef(false); const timerRef = useRef | null>(null); const resetActivity = useCallback(() => { + // Activity behind the lock screen must not silently extend the session. + if (lockedRef.current) return; lastActivityRef.current = Date.now(); + setSecondsUntilLock(null); }, []); const setTimeoutMinutes = useCallback((minutes: TimeoutOption) => { @@ -42,15 +65,45 @@ export function SessionLockProvider({ children }: { children: React.ReactNode }) try { localStorage.setItem(STORAGE_KEY, String(minutes)); } catch {} + lockedRef.current = false; resetActivity(); setLocked(false); }, [resetActivity]); const resume = useCallback(() => { + lockedRef.current = false; setLocked(false); + setUnlockError(null); resetActivity(); }, [resetActivity]); + const lock = useCallback(() => { + lockedRef.current = true; + setLocked(true); + setSecondsUntilLock(null); + setBiometricRequired(isBiometricEnabled()); + try { + sessionStorage.removeItem(WALLET_SESSION_KEY); + } catch {} + recordSecurityEvent({ type: "session_locked" }); + }, []); + + const unlockWithBiometric = async () => { + setUnlocking(true); + setUnlockError(null); + const ok = await verifyBiometric(); + setUnlocking(false); + if (ok) resume(); + else setUnlockError("Biometric check failed. Try again, or disconnect and sign in again."); + }; + + const disconnectAndReload = () => { + try { + sessionStorage.removeItem(WALLET_SESSION_KEY); + } catch {} + window.location.reload(); + }; + useEffect(() => { INTERACTION_EVENTS.forEach((evt) => document.addEventListener(evt, resetActivity, true)); return () => { @@ -61,37 +114,91 @@ export function SessionLockProvider({ children }: { children: React.ReactNode }) useEffect(() => { if (timerRef.current) clearInterval(timerRef.current); - if (timeoutMinutes === null) return; + if (timeoutMinutes === null) { + setSecondsUntilLock(null); + return; + } const ms = timeoutMinutes * 60 * 1000; timerRef.current = setInterval(() => { - if (Date.now() - lastActivityRef.current >= ms) { - setLocked(true); + if (lockedRef.current) return; + const remaining = ms - (Date.now() - lastActivityRef.current); + if (remaining <= 0) { + lock(); + } else if (remaining <= WARNING_SECONDS * 1000) { + setSecondsUntilLock(Math.ceil(remaining / 1000)); } - }, 10_000); + }, TICK_MS); return () => { if (timerRef.current) clearInterval(timerRef.current); }; - }, [timeoutMinutes]); + }, [timeoutMinutes, lock]); return ( - + {children} + {!locked && secondsUntilLock !== null && ( +
+

+ Your session will lock in {secondsUntilLock}s due to inactivity. +

+ +
+ )} {locked && ( -
+
-

Session Locked

+

Session Locked

Your session was locked due to inactivity.

- + {biometricRequired ? ( + <> + + {unlockError && ( +

{unlockError}

+ )} + + + ) : ( + + )}
)} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 87d09486..f0dace2a 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -9,6 +9,7 @@ import { getWalletConnectPublicKey, } from "@/lib/freighter"; import type { WalletType } from "@/lib/freighter"; +import { connectLimiter, enforceRateLimit, recordSecurityEvent } from "@/lib/walletSecurity"; const STORAGE_KEY = "stellarsplit_wallet"; @@ -142,6 +143,12 @@ export function useWallet() { const connect = useCallback(async (type: WalletType) => { setState((prev) => ({ ...prev, connecting: true, error: null })); + try { + enforceRateLimit(connectLimiter, type, "wallet connection"); + } catch (e) { + setState((prev) => ({ ...prev, connecting: false, error: (e as Error).message })); + throw e; + } try { if (type === "freighter") { const address = await connectFreighter(); @@ -155,6 +162,7 @@ export function useWallet() { setState((prev) => ({ ...prev, address: publicKey, walletType: "walletconnect", connecting: false })); return { address: publicKey, walletType: "walletconnect" as WalletType, uri }; } catch (e) { + recordSecurityEvent({ type: "connect_failed", detail: type }); setState((prev) => ({ ...prev, connecting: false, error: String(e) })); throw e; } diff --git a/src/lib/biometric.ts b/src/lib/biometric.ts new file mode 100644 index 00000000..b63e708f --- /dev/null +++ b/src/lib/biometric.ts @@ -0,0 +1,107 @@ +/** + * Optional biometric unlock using the device's platform authenticator + * (Touch ID, Face ID, Windows Hello, Android fingerprint) via WebAuthn. + * + * This is a local presence check used to unlock an idle session — it proves + * the person at the device can pass the OS biometric prompt. It does not + * replace wallet signatures and nothing is sent to a server. + */ + +export const BIOMETRIC_CREDENTIAL_KEY = "stellarsplit_biometric_credential"; + +function toBase64Url(buf: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function fromBase64Url(value: string): ArrayBuffer { + const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)).buffer as ArrayBuffer; +} + +function randomBytes(n: number): ArrayBuffer { + const bytes = new Uint8Array(n); + crypto.getRandomValues(bytes); + return bytes.buffer as ArrayBuffer; +} + +/** True when this browser/device offers a user-verifying platform authenticator. */ +export async function isBiometricAvailable(): Promise { + if (typeof window === "undefined" || !window.PublicKeyCredential || !navigator.credentials) return false; + try { + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + } catch { + return false; + } +} + +export function getBiometricCredentialId(): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(BIOMETRIC_CREDENTIAL_KEY); + } catch { + return null; + } +} + +export function isBiometricEnabled(): boolean { + return getBiometricCredentialId() !== null; +} + +/** Register a platform credential and remember its ID. Prompts the OS biometric dialog. */ +export async function enableBiometric(label = "StellarSplit"): Promise { + if (!(await isBiometricAvailable())) { + throw new Error("Biometric authentication isn't available on this device."); + } + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: randomBytes(32), + rp: { name: "StellarSplit", id: window.location.hostname }, + user: { id: randomBytes(16), name: label, displayName: label }, + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 }, + ], + authenticatorSelection: { + authenticatorAttachment: "platform", + userVerification: "required", + residentKey: "discouraged", + }, + timeout: 60_000, + attestation: "none", + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error("Biometric setup was cancelled."); + window.localStorage.setItem(BIOMETRIC_CREDENTIAL_KEY, toBase64Url(credential.rawId)); +} + +export function disableBiometric(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(BIOMETRIC_CREDENTIAL_KEY); + } catch { + // ignore + } +} + +/** Ask the OS to verify the user with the registered credential. Resolves true on success. */ +export async function verifyBiometric(): Promise { + const id = getBiometricCredentialId(); + if (!id || !navigator.credentials) return false; + try { + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: randomBytes(32), + allowCredentials: [{ type: "public-key", id: fromBase64Url(id), transports: ["internal"] }], + userVerification: "required", + timeout: 60_000, + }, + }); + return assertion !== null; + } catch { + return false; + } +} diff --git a/src/lib/freighter.ts b/src/lib/freighter.ts index 2a37614e..413277f3 100644 --- a/src/lib/freighter.ts +++ b/src/lib/freighter.ts @@ -4,6 +4,7 @@ * All functions are safe to import in Server Components — they guard against * SSR by checking `typeof window` before calling browser-only Freighter APIs. */ +import { enforceRateLimit, recordSecurityEvent, signLimiter } from "./walletSecurity"; export const NETWORK_PASSPHRASE = process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" @@ -110,20 +111,29 @@ export async function isWalletConnected(): Promise { } } -/** Sign a transaction with the currently connected wallet adapter. */ +/** + * Sign a transaction with the currently connected wallet adapter. + * Rate limited, and rejected/failed signatures are logged so repeated + * unexpected signing prompts surface as suspicious activity. + */ export async function signTransaction(xdr: string): Promise { if (typeof window === "undefined") throw new Error("Browser only"); + enforceRateLimit(signLimiter, "sign", "transaction signing"); - // Try WalletConnect first if available try { - const wcKey = await getWalletConnectPublicKey(); - if (wcKey) { - return signWithWalletConnect(xdr); + // Try WalletConnect first if available + let wcKey: string | null = null; + try { + wcKey = await getWalletConnectPublicKey(); + } catch { + // WalletConnect not available, try Freighter } - } catch { - // WalletConnect not available, try Freighter - } + if (wcKey) return await signWithWalletConnect(xdr); - // Fall back to Freighter - return signWithFreighter(xdr); + // Fall back to Freighter + return await signWithFreighter(xdr); + } catch (e) { + recordSecurityEvent({ type: "sign_rejected", detail: e instanceof Error ? e.message : String(e) }); + throw e; + } } diff --git a/src/lib/walletSecurity.ts b/src/lib/walletSecurity.ts new file mode 100644 index 00000000..bdd20e1d --- /dev/null +++ b/src/lib/walletSecurity.ts @@ -0,0 +1,241 @@ +/** + * Client-side wallet security helpers: security tips, secret-key detection, + * rate limiting for wallet actions, and a small local log of security events + * that is scanned for suspicious patterns. + * + * Nothing here ever stores or transmits key material — detection only looks + * at whether a string *looks like* a secret so we can warn the user. + */ +import { StrKey } from "@stellar/stellar-sdk"; + +export interface SecurityTip { + id: string; + title: string; + body: string; +} + +export const SECURITY_TIPS: readonly SecurityTip[] = [ + { + id: "never-share-secret", + title: "Never share your secret key", + body: "StellarSplit will never ask for your secret key (starting with S) or recovery phrase. Anyone who has it controls your funds.", + }, + { + id: "encrypted-wallet", + title: "Keep keys in an encrypted wallet", + body: "Use a wallet like Freighter that encrypts your keys with a password, or a hardware wallet. Don't keep secret keys in notes, screenshots or chat.", + }, + { + id: "check-before-signing", + title: "Read every transaction before signing", + body: "Check the amount, destination and network in your wallet's confirmation screen. Reject anything you didn't start.", + }, + { + id: "check-network", + title: "Confirm the network", + body: "Mainnet uses real funds. Make sure the network badge in the header matches the network in your wallet.", + }, + { + id: "lock-when-away", + title: "Lock up when you step away", + body: "Your session locks automatically after inactivity. Disconnect your wallet on shared or public computers.", + }, +]; + +// ── Secret material detection ──────────────────────────────────────────────── + +const SECRET_SEED_PATTERN = /\bS[A-Z2-7]{55}\b/g; + +/** True when the text contains a valid Stellar secret seed (S…). */ +export function containsSecretKey(text: string): boolean { + return (text.match(SECRET_SEED_PATTERN) ?? []).some((m) => StrKey.isValidEd25519SecretSeed(m)); +} + +/** + * Heuristic for a BIP-39 style recovery phrase: exactly 12, 15, 18, 21 or 24 + * short lowercase words and nothing else. + */ +export function looksLikeRecoveryPhrase(text: string): boolean { + const words = text.trim().split(/\s+/); + if (![12, 15, 18, 21, 24].includes(words.length)) return false; + return words.every((w) => /^[a-z]{3,8}$/.test(w)); +} + +export type SecretKind = "secret_key" | "recovery_phrase"; + +export function detectSecretMaterial(text: string): SecretKind | null { + if (!text) return null; + if (containsSecretKey(text)) return "secret_key"; + if (looksLikeRecoveryPhrase(text)) return "recovery_phrase"; + return null; +} + +/** + * Whether the page is served over an encrypted connection. Localhost counts + * as secure, matching the browser's own `isSecureContext` rules. + */ +export function isConnectionEncrypted(loc: { protocol: string; hostname: string }, secureContext?: boolean): boolean { + if (secureContext !== undefined) return secureContext; + return loc.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(loc.hostname); +} + +// ── Rate limiting ──────────────────────────────────────────────────────────── + +export interface RateLimitResult { + allowed: boolean; + /** Milliseconds until the next attempt is allowed (0 when allowed). */ + retryAfterMs: number; + remaining: number; +} + +export interface RateLimiter { + attempt: (key: string, now?: number) => RateLimitResult; + reset: (key?: string) => void; +} + +/** Sliding-window rate limiter: at most `limit` attempts per `windowMs` per key. */ +export function createRateLimiter({ limit, windowMs }: { limit: number; windowMs: number }): RateLimiter { + const hits = new Map(); + return { + attempt(key, now = Date.now()) { + const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs); + if (recent.length >= limit) { + hits.set(key, recent); + return { allowed: false, retryAfterMs: windowMs - (now - recent[0]), remaining: 0 }; + } + recent.push(now); + hits.set(key, recent); + return { allowed: true, retryAfterMs: 0, remaining: limit - recent.length }; + }, + reset(key) { + if (key === undefined) hits.clear(); + else hits.delete(key); + }, + }; +} + +export class RateLimitError extends Error { + constructor(public readonly retryAfterMs: number, action: string) { + super(`Too many ${action} attempts. Please wait ${Math.ceil(retryAfterMs / 1000)}s and try again.`); + this.name = "RateLimitError"; + } +} + +/** Wallet connection attempts: 5 per minute. */ +export const connectLimiter = createRateLimiter({ limit: 5, windowMs: 60_000 }); +/** Transaction signing requests: 10 per minute. */ +export const signLimiter = createRateLimiter({ limit: 10, windowMs: 60_000 }); + +/** Throw a `RateLimitError` (and log it) when `limiter` refuses `key`. */ +export function enforceRateLimit(limiter: RateLimiter, key: string, action: string): void { + const result = limiter.attempt(key); + if (!result.allowed) { + recordSecurityEvent({ type: "rate_limited", detail: action }); + throw new RateLimitError(result.retryAfterMs, action); + } +} + +// ── Security event log & suspicious activity ──────────────────────────────── + +export type SecurityEventType = + | "connect_failed" + | "sign_rejected" + | "account_changed" + | "secret_detected" + | "rate_limited" + | "session_locked"; + +export interface SecurityEvent { + type: SecurityEventType; + timestamp: number; + detail?: string; +} + +export const SECURITY_EVENTS_KEY = "stellarsplit_security_events"; +export const SECURITY_EVENT_NAME = "stellarsplit:security-event"; +const MAX_EVENTS = 100; + +export function readSecurityEvents(): SecurityEvent[] { + if (typeof window === "undefined") return []; + try { + const parsed = JSON.parse(window.localStorage.getItem(SECURITY_EVENTS_KEY) ?? "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function recordSecurityEvent(event: Omit & { timestamp?: number }): void { + if (typeof window === "undefined") return; + const full: SecurityEvent = { ...event, timestamp: event.timestamp ?? Date.now() }; + try { + const events = [...readSecurityEvents(), full].slice(-MAX_EVENTS); + window.localStorage.setItem(SECURITY_EVENTS_KEY, JSON.stringify(events)); + } catch { + // Storage full or blocked — still notify listeners below. + } + window.dispatchEvent(new CustomEvent(SECURITY_EVENT_NAME, { detail: full })); +} + +export function clearSecurityEvents(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(SECURITY_EVENTS_KEY); + } catch { + // ignore + } +} + +export interface WalletSecurityAlert { + id: string; + severity: "high" | "medium"; + message: string; +} + +const TEN_MINUTES = 10 * 60_000; + +/** Scan recent security events for patterns worth warning the user about. */ +export function detectWalletAnomalies(events: SecurityEvent[], now = Date.now()): WalletSecurityAlert[] { + const recent = events.filter((e) => now - e.timestamp <= TEN_MINUTES); + const count = (type: SecurityEventType) => recent.filter((e) => e.type === type).length; + const alerts: WalletSecurityAlert[] = []; + + if (count("secret_detected") > 0) { + alerts.push({ + id: "secret_detected", + severity: "high", + message: + "A secret key or recovery phrase was entered on this page. If you shared it anywhere, move your funds to a new wallet now.", + }); + } + if (count("connect_failed") >= 3) { + alerts.push({ + id: "connect_failed", + severity: "medium", + message: `${count("connect_failed")} failed wallet connection attempts in the last 10 minutes.`, + }); + } + if (count("sign_rejected") >= 3) { + alerts.push({ + id: "sign_rejected", + severity: "medium", + message: `${count("sign_rejected")} signing requests were rejected or failed in the last 10 minutes. Only approve transactions you started.`, + }); + } + if (count("account_changed") >= 2) { + alerts.push({ + id: "account_changed", + severity: "medium", + message: "The connected wallet account changed several times recently. Make sure you're signing with the right account.", + }); + } + if (count("rate_limited") > 0) { + alerts.push({ + id: "rate_limited", + severity: "medium", + message: "Wallet actions were temporarily blocked because of unusually frequent requests.", + }); + } + + return alerts; +}