Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 186 additions & 51 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"jsdom": "^29.1.1",
Expand Down
2 changes: 1 addition & 1 deletion src/app/(dashboard)/wallet/transfer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ function WalletTransferContent() {
</div>
)}
</div>
}
)
}

export default function WalletTransferPage() {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/security/__tests__/csp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ describe("buildCsp", () => {

const frame = directive(prod, "frame-src") ?? ""
expect(frame).toContain("https://challenges.cloudflare.com")

const img = directive(prod, "img-src") ?? ""
expect(img).toContain("https://mc.yandex.ru")
expect(img).toContain("https://cloudflare-ipfs.com")
expect(img).toContain("https://ipfs.io")
expect(img.split(" ")).not.toContain("https:")
})

it("upgrades insecure requests in production only", () => {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/security/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const CAPTCHA_HOSTS = ["https://*.hcaptcha.com", "https://challenges.cloudflare.

const ANALYTICS_HOSTS = ["https://mc.yandex.ru", "https://mc.yandex.com"]

const IPFS_HOSTS = ["https://cloudflare-ipfs.com", "https://ipfs.io"]

/**
* Reduce a configured URL to a bare origin so it can be used as a CSP source.
* Unset or malformed values contribute nothing rather than widening the policy.
Expand Down Expand Up @@ -92,7 +94,7 @@ export function buildCsp(nonce: string, isDev = process.env.NODE_ENV !== "produc
// React writes component styles as style attributes, and next/font emits
// an inline <style> block; neither can carry a nonce.
["style-src", ["'self'", "'unsafe-inline'"]],
["img-src", ["'self'", "data:", "blob:", "https:"]],
["img-src", ["'self'", "data:", "blob:", ...configuredOrigins(), ...ANALYTICS_HOSTS, ...IPFS_HOSTS]],
["font-src", ["'self'", "data:"]],
["connect-src", connectSrc],
["frame-src", ["'self'", ...CAPTCHA_HOSTS, ...ANALYTICS_HOSTS, "https://verify.walletconnect.com", "https://verify.walletconnect.org"]],
Expand Down
5 changes: 1 addition & 4 deletions src/lib/wallet/adapters/__tests__/walletconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,8 @@ describe("WalletConnect Adapter", () => {
it("connect does not throw synchronously when relay is flagged down", async () => {
mockRelay.status = "down"
let promise: Promise<unknown> | undefined
expect(() => { promise = adapter.connect() }).not.toThrow()
expect(() => { promise = adapter.connect().catch((err) => err) }).not.toThrow()
expect(promise).toBeInstanceOf(Promise)
// Wait for connect() async body to set _pendingReject (via
// getOrInitSignClient resolving the mocked SignClient.init),
// then cancel and consume the rejection so nothing leaks.
await new Promise<void>((resolve) => setTimeout(resolve, 20))
resetWcState()
try { await promise! } catch { /* expected — resetWcState rejected it */ }
Expand Down
20 changes: 15 additions & 5 deletions src/lib/wallet/adapters/walletconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@
* destructive to unrelated sessions.
*/

import { WalletAdapter, WalletAdapterMeta, ConnectOptions } from "../types"
import { WalletAdapter, WalletMeta, NetworkType } from "../types"
import { getRelayMonitor } from "../wc2-relay"
import { getWC2SessionStore } from "../wc2-session-store"
import { validateStellarAddress } from "@/lib/stellar/validate-address"

export interface ConnectOptions {
network?: NetworkType
onUri?: (uri: string) => void
}

// ---------------------------------------------------------------------------
// Module-level singletons (intentional — one client, one WebSocket)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -146,6 +151,11 @@ export function resetWcState(): void {
abortConnect()
}

export async function disconnectWc(): Promise<void> {
const adapter = createWalletConnectAdapter()
await adapter.disconnect()
}

/**
* Legacy adapter for the wallet-selector component.
* The component can subscribe to pairing URI events by passing a function:
Expand Down Expand Up @@ -384,8 +394,8 @@ export function createWalletConnectAdapter(): WalletAdapter & {
async signTransaction(xdr: string) {
if (!currentPublicKey || !currentSession) {
throw {
code: "not_connected",
message: "Not connected to WalletConnect",
code: "not_installed",
message: "WalletConnect not connected",
adapter: "walletconnect",
}
}
Expand Down Expand Up @@ -463,11 +473,11 @@ export function createWalletConnectAdapter(): WalletAdapter & {
return currentPublicKey
},

async getNetwork() {
async getNetwork(): Promise<NetworkType> {
if (!currentSession) return "testnet"
const chainId =
currentSession.namespaces?.stellar?.chains?.[0] || ""
return chainId.includes("public") ? "public" : "testnet"
return (chainId.includes("public") || chainId.includes("mainnet") ? "mainnet" : "testnet") as NetworkType
},
}
}
4 changes: 3 additions & 1 deletion src/lib/wallet/hmac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function saveKeyToStorage(hex: string) {

export function _setHmacKeyForTest(hex: string): void {
inMemoryKey = hexToBytes(hex);
saveKeyToStorage(hex);
}

export function clearHmacKeyCache(): void {
Expand Down Expand Up @@ -77,7 +78,8 @@ function getCachedKey(): Uint8Array | null {
async function fetchKeyFromServer(): Promise<Uint8Array> {
if (typeof window === "undefined")
throw new Error("Cannot fetch HMAC key server-side");
const res = await fetch("/api/wallet/hmac/key");
const origin = window.location?.origin && window.location.origin !== "null" ? window.location.origin : "http://localhost:3000";
const res = await fetch(`${origin}/api/wallet/hmac/key`);
if (!res.ok) throw new Error(`Failed to get HMAC key: ${res.status}`);
const body = (await res.json()) as { keyHex: string };
const bytes = hexToBytes(body.keyHex);
Expand Down
10 changes: 2 additions & 8 deletions src/lib/wallet/session-manager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
import type { WalletAdapter, WalletSession, EncryptedSessionStore, WalletId } from "./types"
import { computeHmacSha256Sync } from "./hmac"
import { SESSION_TTL_MS } from "./session-lifecycle"

const STORAGE_KEY = "moistello_wallet_sessions"
const SESSION_TTL = SESSION_TTL_MS
const CHANNEL_NAME = "moistello-wallet"
import type {
WalletAdapter,
WalletSession,
Expand All @@ -16,9 +9,10 @@ import {
encryptToStorage,
decryptFromStorage,
} from "@/lib/security/encryption";
import { SESSION_TTL_MS } from "./session-lifecycle";

const STORAGE_KEY = "moistello_wallet_sessions";
const SESSION_TTL = 7 * 24 * 60 * 60 * 1000;
const SESSION_TTL = SESSION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000;
const CHANNEL_NAME = "moistello-wallet";

export class WalletSessionManager {
Expand Down
12 changes: 11 additions & 1 deletion src/lib/wallet/wc2-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@ export class WCRelayMonitor {
return this._status
}

recordOutcome(success: boolean, latencyMs: number): void {
recordOutcome(arg1: boolean | string, arg2?: number | boolean, arg3?: number): void {
let success: boolean
let latencyMs = 0
if (typeof arg1 === "string") {
success = Boolean(arg2)
latencyMs = typeof arg3 === "number" ? arg3 : 0
} else {
success = Boolean(arg1)
latencyMs = typeof arg2 === "number" ? arg2 : 0
}

this.window.push({ success, latencyMs })
if (this.window.length > WINDOW_SIZE) {
this.window.shift()
Expand Down
3 changes: 1 addition & 2 deletions src/lib/wallet/wc2-session-store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { NetworkType } from "./types"
import { computeHmacSha256Sync } from "./hmac"
import { SESSION_TTL_MS } from "./session-lifecycle"
import { computeHmacSha256Sync, isHmacKeyReady, withHmacKey } from "./hmac"
import { SESSION_TTL_MS } from "./session-lifecycle"

const STORAGE_KEY = "moistello_wc2_session"
const SESSION_TTL = SESSION_TTL_MS
Expand Down
60 changes: 60 additions & 0 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -1 +1,61 @@
import "@testing-library/jest-dom/vitest"

class StorageMock implements Storage {
[name: string]: any

get length() {
return Object.keys(this).filter(
(k) => !["length", "clear", "getItem", "key", "removeItem", "setItem"].includes(k)
).length
}

clear() {
for (const key of Object.keys(this)) {
if (!["length", "clear", "getItem", "key", "removeItem", "setItem"].includes(key)) {
delete this[key]
}
}
}

getItem(key: string): string | null {
return Object.prototype.hasOwnProperty.call(this, key) ? String(this[key]) : null
}

key(index: number): string | null {
const keys = Object.keys(this).filter(
(k) => !["length", "clear", "getItem", "key", "removeItem", "setItem"].includes(k)
)
return keys[index] ?? null
}

removeItem(key: string) {
delete this[key]
}

setItem(key: string, value: string) {
this[key] = String(value)
}
}

// In Node 22+, globalThis.localStorage is partially defined but non-functional without a file.
// Ensure functional localStorage and sessionStorage exist on both window and globalThis.
const mockLocal = new StorageMock()
const mockSession = new StorageMock()

try {
globalThis.localStorage.setItem("__test", "1")
globalThis.localStorage.removeItem("__test")
} catch {
Object.defineProperty(globalThis, "localStorage", { value: mockLocal, configurable: true, writable: true })
Object.defineProperty(globalThis, "sessionStorage", { value: mockSession, configurable: true, writable: true })
}

if (typeof window !== "undefined") {
try {
window.localStorage.setItem("__test", "1")
window.localStorage.removeItem("__test")
} catch {
Object.defineProperty(window, "localStorage", { value: mockLocal, configurable: true, writable: true })
Object.defineProperty(window, "sessionStorage", { value: mockSession, configurable: true, writable: true })
}
}
4 changes: 3 additions & 1 deletion src/stores/__tests__/auth-flow-store-sign.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ function resetStore() {
describe("AuthFlowStore - signAndSubmit", () => {
beforeEach(() => {
resetStore()
vi.clearAllMocks()
mockPost.mockReset()
mockSignMessage.mockReset()
mockSetTokens.mockReset()
})

it("persists auth flow state in localStorage so other tabs can receive storage events", () => {
Expand Down
6 changes: 4 additions & 2 deletions src/stores/__tests__/auth-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useAuthFlowStore } from "../auth-flow-store";
import { useAuthStore } from "../auth-store";
import { _setHmacKeyForTest } from "@/lib/wallet/hmac";

describe("Auth Security - Token Storage", () => {
beforeEach(() => {
// Clear all stores and localStorage
localStorage.clear();
_setHmacKeyForTest("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
useAuthFlowStore.getState().reset();
useAuthStore.getState().logout();
});
Expand Down Expand Up @@ -95,15 +97,15 @@ describe("Auth Security - Token Storage", () => {
expect(tokenKeys).toHaveLength(0);
});

it("should clear legacy token storage on init", () => {
it("should clear legacy token storage on init", async () => {
// Simulate legacy tokens in storage
localStorage.setItem("moistello_token", "legacy-token");
localStorage.setItem("moistello_refresh", "legacy-refresh");
localStorage.setItem("moistello_access_token", "legacy-access");

// Re-import to trigger cleanup
vi.resetModules();
import("../auth-store");
await import("../auth-store");

// Legacy keys should be removed
expect(localStorage.getItem("moistello_token")).toBeNull();
Expand Down
36 changes: 30 additions & 6 deletions src/stores/auth-flow-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,25 @@ export async function verifyPasskeyRevocation(): Promise<void> {
}
}

const fallbackStorage: Storage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
length: 0,
key: () => null,
};

function getLocalStorage(): Storage {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
if (typeof localStorage !== "undefined") {
return localStorage;
}
return fallbackStorage;
}

export const useAuthFlowStore = create<AuthFlowStore>()(
persist(
devtools(
Expand Down Expand Up @@ -432,7 +451,11 @@ export const useAuthFlowStore = create<AuthFlowStore>()(
// Bail if a concurrent abort was triggered while we awaited the nonce.
if (abortController.signal.aborted) return;

const nonce = nonceResponse.data.nonce.nonce;
const rawNonce = (nonceResponse as any)?.data?.nonce?.nonce ?? (nonceResponse as any)?.data?.nonce ?? (nonceResponse as any)?.nonce;
if (!rawNonce || typeof rawNonce !== "string") {
throw new Error("Invalid nonce received from server");
}
const nonce = rawNonce;

const signingState = get();
if (
Expand Down Expand Up @@ -525,14 +548,15 @@ export const useAuthFlowStore = create<AuthFlowStore>()(

if (abortController.signal.aborted) return;

const d = authResponse.data;
const d = (authResponse as any)?.data ?? authResponse;
const currentPasskeyVersion = get().passkeyVersion;
const expectedVersion = d?.expectedPasskeyVersion ?? (authResponse as any)?.expectedPasskeyVersion;
if (
d.expectedPasskeyVersion !== undefined &&
d.expectedPasskeyVersion > currentPasskeyVersion
expectedVersion !== undefined &&
expectedVersion > currentPasskeyVersion
) {
set({
passkeyVersion: d.expectedPasskeyVersion,
passkeyVersion: expectedVersion,
passkeyRevoked: true,
});
const msg =
Expand Down Expand Up @@ -817,7 +841,7 @@ export const useAuthFlowStore = create<AuthFlowStore>()(
{
name: "moistello-auth-flow",
version: 3,
storage: createJSONStorage(() => localStorage),
storage: createJSONStorage(() => getLocalStorage()),
partialize: (state) => ({
step: state.step,
// auth field removed: nonce/signature now rely only on HttpOnly cookies
Expand Down
Loading