From 12883a0cd0d02148f7a202ba50f896e88b8236d3 Mon Sep 17 00:00:00 2001 From: Isaac Araoluwa Abraham Date: Thu, 22 Oct 2026 13:39:54 +0000 Subject: [PATCH] feat: implement mobile biometric authentication --- src/__tests__/biometric.test.ts | 81 ++++++++ .../security/WalletSecuritySettings.tsx | 4 + src/lib/biometric.ts | 77 +++++--- src/lib/walletSecurity.ts | 177 +++++++++++++++++- 4 files changed, 313 insertions(+), 26 deletions(-) create mode 100644 src/__tests__/biometric.test.ts diff --git a/src/__tests__/biometric.test.ts b/src/__tests__/biometric.test.ts new file mode 100644 index 0000000..22b47c3 --- /dev/null +++ b/src/__tests__/biometric.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + BIOMETRIC_CREDENTIAL_KEY, + enableBiometric, + isBiometricAvailable, + verifyBiometric, +} from "@/lib/biometric"; +import { readSecurityEvents } from "@/lib/walletSecurity"; + +const credentialId = new Uint8Array([1, 2, 3, 4]).buffer; + +function installWebAuthnMocks({ verified = true, matchingId = true } = {}) { + const authenticatorData = new Uint8Array(37); + authenticatorData[32] = verified ? 0x04 : 0x01; + const credentials = { + create: vi.fn().mockResolvedValue({ rawId: credentialId }), + get: vi.fn().mockResolvedValue({ + type: "public-key", + rawId: matchingId ? credentialId : new Uint8Array([9, 9, 9]).buffer, + response: { authenticatorData: authenticatorData.buffer }, + }), + }; + + Object.defineProperty(window, "isSecureContext", { configurable: true, value: true }); + Object.defineProperty(window, "PublicKeyCredential", { + configurable: true, + value: { isUserVerifyingPlatformAuthenticatorAvailable: vi.fn().mockResolvedValue(true) }, + }); + Object.defineProperty(navigator, "credentials", { configurable: true, value: credentials }); + return credentials; +} + +describe("biometric authentication", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("is available only in secure contexts with a platform authenticator", async () => { + installWebAuthnMocks(); + expect(await isBiometricAvailable()).toBe(true); + + Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); + expect(await isBiometricAvailable()).toBe(false); + }); + + it("enrolls a platform credential and records enrollment analytics", async () => { + const credentials = installWebAuthnMocks(); + await enableBiometric(); + + expect(credentials.create).toHaveBeenCalledWith(expect.objectContaining({ + publicKey: expect.objectContaining({ + authenticatorSelection: expect.objectContaining({ + authenticatorAttachment: "platform", + userVerification: "required", + }), + }), + })); + expect(localStorage.getItem(BIOMETRIC_CREDENTIAL_KEY)).toBe("AQIDBA"); + expect(readSecurityEvents()[0].type).toBe("biometric_enrolled"); + }); + + it("accepts only the enrolled credential with user verification", async () => { + installWebAuthnMocks(); + localStorage.setItem(BIOMETRIC_CREDENTIAL_KEY, "AQIDBA"); + expect(await verifyBiometric()).toBe(true); + expect(readSecurityEvents()[0].type).toBe("biometric_success"); + }); + + it("rejects an assertion without user verification or with another credential", async () => { + localStorage.setItem(BIOMETRIC_CREDENTIAL_KEY, "AQIDBA"); + installWebAuthnMocks({ verified: false }); + expect(await verifyBiometric()).toBe(false); + expect(readSecurityEvents()[0].type).toBe("biometric_failed"); + + localStorage.clear(); + readSecurityEvents(); + localStorage.setItem(BIOMETRIC_CREDENTIAL_KEY, "AQIDBA"); + installWebAuthnMocks({ matchingId: false }); + expect(await verifyBiometric()).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/components/security/WalletSecuritySettings.tsx b/src/components/security/WalletSecuritySettings.tsx index d3654e0..3c7faf3 100644 --- a/src/components/security/WalletSecuritySettings.tsx +++ b/src/components/security/WalletSecuritySettings.tsx @@ -25,6 +25,10 @@ const EVENT_LABELS: Record = { secret_detected: "Secret key or recovery phrase entered", rate_limited: "Wallet action rate limited", session_locked: "Session locked after inactivity", + biometric_enrolled: "Biometric unlock enabled", + biometric_disabled: "Biometric unlock disabled", + biometric_success: "Biometric verification succeeded", + biometric_failed: "Biometric verification failed", }; /** diff --git a/src/lib/biometric.ts b/src/lib/biometric.ts index b63e708..2c61274 100644 --- a/src/lib/biometric.ts +++ b/src/lib/biometric.ts @@ -1,3 +1,5 @@ +import { recordSecurityEvent } from "@/lib/walletSecurity"; + /** * Optional biometric unlock using the device's platform authenticator * (Touch ID, Face ID, Windows Hello, Android fingerprint) via WebAuthn. @@ -31,6 +33,7 @@ function randomBytes(n: number): 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; + if (!window.isSecureContext) return false; try { return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } catch { @@ -48,49 +51,60 @@ export function getBiometricCredentialId(): string | null { } export function isBiometricEnabled(): boolean { - return getBiometricCredentialId() !== null; + return Boolean(getBiometricCredentialId()); } /** 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", + try { + 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", }, - 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)); + })) as PublicKeyCredential | null; + if (!credential?.rawId.byteLength) throw new Error("Biometric setup was cancelled."); + window.localStorage.setItem(BIOMETRIC_CREDENTIAL_KEY, toBase64Url(credential.rawId)); + recordSecurityEvent({ type: "biometric_enrolled" }); + } catch (error) { + recordSecurityEvent({ type: "biometric_failed", detail: "enrollment" }); + throw error; + } } export function disableBiometric(): void { if (typeof window === "undefined") return; + const wasEnabled = isBiometricEnabled(); try { window.localStorage.removeItem(BIOMETRIC_CREDENTIAL_KEY); } catch { // ignore } + if (wasEnabled) recordSecurityEvent({ type: "biometric_disabled" }); } /** 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; + if (!id || !navigator.credentials) { + recordSecurityEvent({ type: "biometric_failed", detail: "unavailable" }); + return false; + } try { const assertion = await navigator.credentials.get({ publicKey: { @@ -100,8 +114,21 @@ export async function verifyBiometric(): Promise { timeout: 60_000, }, }); - return assertion !== null; + const credentialId = fromBase64Url(id); + const rawId = (assertion as PublicKeyCredential | null)?.rawId; + const response = (assertion as PublicKeyCredential | null)?.response as AuthenticatorAssertionResponse | undefined; + const authenticatorData = response?.authenticatorData; + const matchesCredential = Boolean( + rawId && rawId.byteLength === credentialId.byteLength && + new Uint8Array(rawId).every((byte, index) => byte === new Uint8Array(credentialId)[index]), + ); + const userVerified = Boolean(authenticatorData && authenticatorData.byteLength >= 33 && + (new Uint8Array(authenticatorData)[32] & 0x04) !== 0); + const verified = assertion?.type === "public-key" && matchesCredential && userVerified; + recordSecurityEvent({ type: verified ? "biometric_success" : "biometric_failed", detail: "verification" }); + return verified; } catch { + recordSecurityEvent({ type: "biometric_failed", detail: "verification" }); return false; } } diff --git a/src/lib/walletSecurity.ts b/src/lib/walletSecurity.ts index e37687e..c2b1293 100644 --- a/src/lib/walletSecurity.ts +++ b/src/lib/walletSecurity.ts @@ -17,6 +17,8 @@ * implements. */ +import { StrKey } from "@stellar/stellar-sdk"; + /** Matches common secret-key shapes so they never make it into logs. */ const SECRET_LIKE_PATTERN = /^S[A-Z0-9]{55}$/; // Stellar secret seed (StrKey "S...") @@ -66,7 +68,6 @@ export function secureRandomBytes(length: number): Uint8Array { crypto.getRandomValues(bytes); } else { // Node.js server-side fallback. - // eslint-disable-next-line @typescript-eslint/no-var-requires const nodeCrypto = require("crypto"); nodeCrypto.randomFillSync(bytes); } @@ -89,6 +90,180 @@ export function secureWipe(buffer: Uint8Array): void { buffer.fill(0); } +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 or recovery phrase. Anyone who has it controls your funds." }, + { id: "encrypted-wallet", title: "Keep keys in an encrypted wallet", body: "Use an encrypted wallet or 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 matches the network in your wallet." }, + { id: "lock-when-away", title: "Lock when you step away", body: "Your session locks automatically after inactivity. Disconnect your wallet on shared or public computers." }, +]; + +const SECRET_SEED_PATTERN = /\bS[A-Z2-7]{55}\b/g; + +export function containsSecretKey(text: string): boolean { + return (text.match(SECRET_SEED_PATTERN) ?? []).some((value) => StrKey.isValidEd25519SecretSeed(value)); +} + +export function looksLikeRecoveryPhrase(text: string): boolean { + const words = text.trim().split(/\s+/); + return [12, 15, 18, 21, 24].includes(words.length) && words.every((word) => /^[a-z]{3,8}$/.test(word)); +} + +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; +} + +export function isConnectionEncrypted( + location: { protocol: string; hostname: string }, + secureContext?: boolean, +): boolean { + if (secureContext !== undefined) return secureContext; + return location.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname); +} + +export interface RateLimitResult { + allowed: boolean; + retryAfterMs: number; + remaining: number; +} + +export interface RateLimiter { + attempt: (key: string, now?: number) => RateLimitResult; + reset: (key?: string) => void; +} + +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((timestamp) => now - timestamp < 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"; + } +} + +export const connectLimiter = createRateLimiter({ limit: 5, windowMs: 60_000 }); +export const signLimiter = createRateLimiter({ limit: 10, windowMs: 60_000 }); + +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); + } +} + +export type SecurityEventType = + | "connect_failed" + | "sign_rejected" + | "account_changed" + | "secret_detected" + | "rate_limited" + | "session_locked" + | "biometric_enrolled" + | "biometric_disabled" + | "biometric_success" + | "biometric_failed"; + +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: unknown = JSON.parse(window.localStorage.getItem(SECURITY_EVENTS_KEY) ?? "[]"); + return Array.isArray(parsed) ? parsed as SecurityEvent[] : []; + } 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 { + window.localStorage.setItem(SECURITY_EVENTS_KEY, JSON.stringify([...readSecurityEvents(), full].slice(-MAX_EVENTS))); + } catch { + // Still notify listeners if local storage is unavailable. + } + 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 unavailable storage. + } +} + +export interface WalletSecurityAlert { + id: string; + severity: "high" | "medium"; + message: string; +} + +const TEN_MINUTES = 10 * 60_000; + +export function detectWalletAnomalies(events: SecurityEvent[], now = Date.now()): WalletSecurityAlert[] { + const recent = events.filter((event) => now - event.timestamp <= TEN_MINUTES); + const count = (type: SecurityEventType) => recent.filter((event) => event.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; +} + // --------------------------------------------------------------------------- // Idle session timeout for a connected wallet // ---------------------------------------------------------------------------