diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index c5b3ca5..8599dc8 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -1237,6 +1237,19 @@ export function StellarReceive() { void scanPayments(retentionGap.oldestAvailableLedger); }, [address, retentionGap, scanPayments]); + // (Re)seal the background-scan key whenever it can be used: notifications on, + // worker ready, keys unlocked. Lock (disconnect / app idle lock) removes the + // wrapping key; this puts a fresh one back once the keys exist again. + const registerScanKey = notifications.registerViewingKey; + const scanNotificationsEnabled = notifications.state.enabled; + const scanWorkerReady = notifications.state.swRegistered; + useEffect(() => { + if (!address || !stellarKeys || !scanNotificationsEnabled || !scanWorkerReady) return; + registerScanKey(address, stellarKeys).catch((error: unknown) => { + console.error('Failed to register viewing key:', error); + }); + }, [address, stellarKeys, scanNotificationsEnabled, scanWorkerReady, registerScanKey]); + const handleToggleNotifications = useCallback(async () => { if (notifications.state.enabled) { await notifications.disableNotifications(); @@ -1244,12 +1257,9 @@ export function StellarReceive() { await notifications.unregisterViewingKey(address); } } else { - await notifications.enableNotifications(); - if (address && stellarKeys) { - await notifications.registerViewingKey(address, stellarKeys); - } + await notifications.enableNotifications(); // the effect above seals the key } - }, [notifications, address, stellarKeys]); + }, [notifications, address]); const handleFireTestNotification = useCallback(async () => { try { diff --git a/src/context/StealthKeysContext.lifecycle.test.tsx b/src/context/StealthKeysContext.lifecycle.test.tsx new file mode 100644 index 0000000..f44146d --- /dev/null +++ b/src/context/StealthKeysContext.lifecycle.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { act, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ScanKeyBackend } from '@/sw/scanKeyStore'; +import { scanPages } from '@/sw/backgroundScan'; +import { MemoryScanKeyBackend } from '@/sw/testing/memoryScanKeyBackend'; + +const holder = vi.hoisted(() => ({ backend: undefined as ScanKeyBackend | undefined })); + +vi.mock('@/sw/idbScanKeyBackend', () => ({ + createIdbScanKeyBackend: () => holder.backend, +})); +vi.mock('@/context/StellarWalletContext', async () => { + const { createContext } = await import('react'); + return { StellarWalletContext: createContext(null) }; +}); + +import { StellarWalletContext } from '@/context/StellarWalletContext'; +import { StealthKeysProvider, useStealthKeys } from './StealthKeysContext'; + +const ACCOUNT = 'GTESTACCOUNT'; + +let disconnect: (() => void) | undefined; +const wallet = { + subscribeToDisconnect: (listener: () => void) => { + disconnect = listener; + return () => { + if (disconnect === listener) disconnect = undefined; + }; + }, +}; + +let ctx!: ReturnType; +function Probe() { + ctx = useStealthKeys(); + return null; +} + +function mountApp() { + return render( + + + + + , + ); +} + +let backend: MemoryScanKeyBackend; +const unlocked = async () => (await backend.getWrappingKey(ACCOUNT)) !== undefined; + +beforeEach(async () => { + backend = new MemoryScanKeyBackend(); + holder.backend = backend; + vi.stubGlobal('indexedDB', {}); + disconnect = undefined; + // A registered account: sealed blob + wrapping key (contents are irrelevant to locking). + await backend.putSealed( + { + publicKey: ACCOUNT, + version: 1, + iv: new Uint8Array(12), + ciphertext: new Uint8Array(32), + sealedAt: 0, + }, + {} as CryptoKey, + ); +}); + +describe('scan-key lock lifecycle', () => { + it('wallet disconnect locks the scan key and the worker can no longer scan', async () => { + mountApp(); + expect(await unlocked()).toBe(true); + + act(() => disconnect!()); + + await vi.waitFor(async () => expect(await unlocked()).toBe(false)); + expect(await backend.getRecord(ACCOUNT)).toBeDefined(); // blob stays, but it is inert + + const readPages = vi.fn(async () => {}); + const commit = vi.fn(async () => {}); + const result = await scanPages({ + backend, + publicKey: ACCOUNT, + readPages, + commit, + notifyMatch: vi.fn(async () => {}), + }); + expect(result).toEqual({ status: 'locked' }); + expect(readPages).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); + }); + + it('the app idle re-lock (clearStellar) locks the scan key', async () => { + mountApp(); + act(() => { + ctx.clearStellar(); + }); + await vi.waitFor(async () => expect(await unlocked()).toBe(false)); + }); + + it('recovery mode keeps its keys, so it does not lock the scan key', async () => { + mountApp(); + act(() => ctx.setIsRecoveryMode(true)); + act(() => disconnect!()); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(await unlocked()).toBe(true); + }); + + it('leaving recovery mode locks the scan key', async () => { + mountApp(); + act(() => ctx.setIsRecoveryMode(true)); + act(() => { + ctx.exitRecoveryMode(); + }); + await vi.waitFor(async () => expect(await unlocked()).toBe(false)); + }); +}); diff --git a/src/context/StealthKeysContext.tsx b/src/context/StealthKeysContext.tsx index 32eedba..215f323 100644 --- a/src/context/StealthKeysContext.tsx +++ b/src/context/StealthKeysContext.tsx @@ -6,6 +6,7 @@ import type { StealthKeys as SolanaStealthKeys } from '@wraith-protocol/sdk/chai import type { StealthKeys as CKBStealthKeys } from '@wraith-protocol/sdk/chains/ckb'; import { hexToBytes, type RecoveryKitData } from '@/lib/stellar/recoveryKit'; import { importLabels } from '@/lib/stealthLabels'; +import { lockStellarScanKeys } from '@/sw/scanKeyLifecycle'; interface StealthKeysContextValue { evmKeys: EVMStealthKeys | null; @@ -72,6 +73,7 @@ export function StealthKeysProvider({ children }: { children: React.ReactNode }) if (isRecoveryMode) return; setStellarKeys(null); setStellarMetaAddress(null); + void lockStellarScanKeys(); }, [isRecoveryMode]); const clearSolana = useCallback(() => { @@ -97,6 +99,7 @@ export function StealthKeysProvider({ children }: { children: React.ReactNode }) setSolanaMetaAddress(null); setCkbKeys(null); setCkbMetaAddress(null); + void lockStellarScanKeys(); }, []); const restoreFromRecoveryKit = useCallback((kitData: RecoveryKitData) => { diff --git a/src/hooks/useStellarNotifications.ts b/src/hooks/useStellarNotifications.ts index c0225c8..4c11bb5 100644 --- a/src/hooks/useStellarNotifications.ts +++ b/src/hooks/useStellarNotifications.ts @@ -4,19 +4,26 @@ * Handles: * - Service worker registration * - Notification permission requests - * - Encrypted viewing key storage in IndexedDB + * - Sealing the minimum scan material (viewing key + spending PUBLIC key) * - Opt-in/opt-out flow * - Manual notification firing for demo + * + * The spending scalar is never read here, never sent to a worker, never stored. */ import { useState, useEffect, useCallback, useRef } from 'react'; import type { StealthKeys } from '@wraith-protocol/sdk/chains/stellar'; +import { createIdbScanKeyBackend } from '../sw/idbScanKeyBackend'; +import { + hasScanKey, + removeScanKey, + sealScanKey, + toScanKeyMaterial, + wipeScanKeyMaterial, +} from '../sw/scanKeyStore'; import { getAppServiceWorkerRegistration } from '@/lib/swRegistration'; const STORAGE_KEY_OPT_IN = 'wraith:stellar:notifications-opt-in'; -const DB_NAME = 'wraith-stellar-notifications'; -const DB_VERSION = 1; -const STORE_NAME = 'viewing-keys'; export interface NotificationState { enabled: boolean; @@ -50,15 +57,6 @@ export function useStellarNotifications(): UseStellarNotificationsReturn { const swRef = useRef(null); - // Helper function to convert hex string to Uint8Array - const hexToBytes = useCallback((hex: string): Uint8Array => { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return bytes; - }, []); - // Check browser support useEffect(() => { const supported = @@ -81,7 +79,8 @@ export function useStellarNotifications(): UseStellarNotificationsReturn { async function registerSW() { try { - const registration = await getAppServiceWorkerRegistration(); + // app-sw.ts is the registered worker and contains the scan/notification handlers. + const registration = await navigator.serviceWorker.ready; if (!registration) throw new Error('App service worker is not registered'); if (cancelled) return; @@ -123,49 +122,6 @@ export function useStellarNotifications(): UseStellarNotificationsReturn { }; }, [state.supported, state.enabled]); - // Encrypt data using Web Crypto API - const encryptData = useCallback(async (data: Uint8Array, key: CryptoKey): Promise => { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - data.buffer as ArrayBuffer, - ); - - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(encrypted), iv.length); - - return Array.from(combined) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - }, []); - - // Derive encryption key from wallet signature - const deriveEncryptionKey = useCallback(async (signature: string): Promise => { - const encoder = new TextEncoder(); - const keyMaterial = await crypto.subtle.importKey( - 'raw', - encoder.encode(signature), - 'PBKDF2', - false, - ['deriveKey'], - ); - - return crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: encoder.encode('wraith-stellar-notifications'), - iterations: 100000, - hash: 'SHA-256', - }, - keyMaterial, - { name: 'AES-GCM', length: 256 }, - false, - ['encrypt', 'decrypt'], - ); - }, []); - // Request notification permission const requestPermission = useCallback(async (): Promise => { if (!state.supported) return false; @@ -207,103 +163,34 @@ export function useStellarNotifications(): UseStellarNotificationsReturn { setState((prev: NotificationState) => ({ ...prev, enabled: false, error: null })); }, []); - // Register viewing key with service worker + // Register (or re-register after a lock) the scan material for an account. const registerViewingKey = useCallback( async (publicKey: string, stealthKeys: StealthKeys) => { if (!swRef.current || !state.enabled) { throw new Error('Service worker not registered or notifications disabled'); } + // Copies only the viewing key and the spending PUBLIC key. + const material = toScanKeyMaterial(stealthKeys); try { - // Derive encryption key from spending scalar (this is wallet-derived) - const scalarHex = stealthKeys.spendingScalar.toString(16).padStart(64, '0'); - const encryptionKey = await deriveEncryptionKey(scalarHex); - - // Encrypt the viewing key components - const viewingKeyBytes = - typeof stealthKeys.viewingKey === 'string' - ? new TextEncoder().encode(stealthKeys.viewingKey) - : (stealthKeys.viewingKey as Uint8Array); - const spendingPubKeyBytes = stealthKeys.spendingPubKey; - const spendingScalarBytes = hexToBytes(scalarHex); - - const encryptedViewingKey = await encryptData(viewingKeyBytes, encryptionKey); - const encryptedSpendingPubKey = await encryptData(spendingPubKeyBytes, encryptionKey); - const encryptedSpendingScalar = await encryptData(spendingScalarBytes, encryptionKey); - - // Send to service worker - swRef.current.active?.postMessage({ - type: 'REGISTER_VIEWING_KEY', - publicKey, - encryptedViewingKey, - encryptedSpendingPubKey, - encryptedSpendingScalar, - }); - - // Also store in IndexedDB for backup - const db = await new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME, { keyPath: 'publicKey' }); - } - }; - }); - - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - store.put({ - publicKey, - encryptedViewingKey, - encryptedSpendingPubKey, - encryptedSpendingScalar, - timestamp: Date.now(), - }); - - await new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }); - - db.close(); + // Sealed straight into IndexedDB; nothing secret goes through postMessage. + await sealScanKey(createIdbScanKeyBackend(), publicKey, material); + swRef.current.active?.postMessage({ type: 'REGISTER_VIEWING_KEY', publicKey }); } catch (error) { console.error('Failed to register viewing key:', error); throw error; + } finally { + wipeScanKeyMaterial(material); } }, - [state.enabled, deriveEncryptionKey, encryptData], + [state.enabled], ); - // Unregister viewing key + // Key removal: wipes the sealed blob and its wrapping key. const unregisterViewingKey = useCallback(async (publicKey: string) => { - if (!swRef.current) return; - try { - swRef.current.active?.postMessage({ - type: 'UNREGISTER_VIEWING_KEY', - publicKey, - }); - - // Also remove from IndexedDB - const db = await new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - store.delete(publicKey); - - await new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }); - - db.close(); + await removeScanKey(createIdbScanKeyBackend(), publicKey); + swRef.current?.active?.postMessage({ type: 'UNREGISTER_VIEWING_KEY', publicKey }); } catch (error) { console.error('Failed to unregister viewing key:', error); } @@ -312,22 +199,7 @@ export function useStellarNotifications(): UseStellarNotificationsReturn { // Check if key is registered const isKeyRegistered = useCallback(async (publicKey: string): Promise => { try { - const db = await new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - - const result = await new Promise((resolve, reject) => { - const transaction = db.transaction([STORE_NAME], 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.get(publicKey); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(!!request.result); - }); - - db.close(); - return result; + return await hasScanKey(createIdbScanKeyBackend(), publicKey); } catch (error) { console.error('Failed to check key registration:', error); return false; diff --git a/src/lib/stellar/announcementEvent.test.ts b/src/lib/stellar/announcementEvent.test.ts new file mode 100644 index 0000000..0bb7e55 --- /dev/null +++ b/src/lib/stellar/announcementEvent.test.ts @@ -0,0 +1,94 @@ +import { Address, xdr } from '@stellar/stellar-sdk'; +import { describe, expect, it } from 'vitest'; +import { + bytesToHex, + deriveStealthKeys, + generateStealthAddress, +} from '@wraith-protocol/sdk/chains/stellar'; +import { parseAnnouncementEvent, decodeAnnouncementEvents } from './announcementEvent'; + +const sym = (s: string) => xdr.ScVal.scvSymbol(s); +const u32 = (n: number) => xdr.ScVal.scvU32(n); +const addr = (a: string) => xdr.ScVal.scvAddress(new Address(a).toScAddress()); +const bytes = (b: Uint8Array) => xdr.ScVal.scvBytes(Buffer.from(b)); +const b64 = (v: xdr.ScVal) => v.toXDR('base64'); + +function randomSignature(): Uint8Array { + const out = new Uint8Array(64); + crypto.getRandomValues(out); + return out; +} + +function fixture() { + const a = deriveStealthKeys(randomSignature()); + const b = deriveStealthKeys(randomSignature()); + const first = generateStealthAddress(a.spendingPubKey, a.viewingPubKey); + const second = generateStealthAddress(b.spendingPubKey, b.viewingPubKey); + return { + stealth: first.stealthAddress, + caller: second.stealthAddress, + eph: first.ephemeralPubKey, + meta: new Uint8Array([7, 1, 2]), + }; +} + +describe('parseAnnouncementEvent', () => { + it('decodes a v1 event (3 topics, stealth address in topic 2)', () => { + const f = fixture(); + const event = { + topic: [b64(sym('announce')), b64(u32(1)), b64(addr(f.stealth))], + value: b64(xdr.ScVal.scvVec([addr(f.caller), bytes(f.eph), bytes(f.meta)])), + }; + expect(parseAnnouncementEvent(event)).toEqual({ + schemeId: 1, + stealthAddress: f.stealth, + caller: f.caller, + ephemeralPubKey: bytesToHex(f.eph), + metadata: bytesToHex(f.meta), + }); + }); + + it('decodes a v2 event (4 topics, stealth address in the value)', () => { + const f = fixture(); + const event = { + contractId: 'CANNOUNCER', + topic: [b64(sym('announce')), b64(u32(2)), b64(u32(7)), b64(u32(0))], + value: b64(xdr.ScVal.scvVec([addr(f.stealth), bytes(f.eph), bytes(f.meta)])), + }; + expect(parseAnnouncementEvent(event)).toEqual({ + schemeId: 2, + stealthAddress: f.stealth, + caller: 'CANNOUNCER', + ephemeralPubKey: bytesToHex(f.eph), + metadata: bytesToHex(f.meta), + }); + }); + + it('returns null for unknown shapes', () => { + const f = fixture(); + const topics = [b64(sym('announce')), b64(u32(2)), b64(u32(7)), b64(u32(0))]; + const value = b64(xdr.ScVal.scvVec([addr(f.stealth), bytes(f.eph), bytes(f.meta)])); + expect(parseAnnouncementEvent({ topic: [...topics, topics[0]], value })).toBeNull(); + expect(parseAnnouncementEvent({ topic: topics.slice(0, 2), value })).toBeNull(); + expect(parseAnnouncementEvent({ topic: 'nope', value })).toBeNull(); + const shortValue = b64(xdr.ScVal.scvVec([addr(f.stealth), bytes(f.eph)])); + expect(parseAnnouncementEvent({ topic: topics, value: shortValue })).toBeNull(); + }); +}); + +describe('decodeAnnouncementEvents', () => { + it('decodes valid v1 and v2 events and skips junk', () => { + const f = fixture(); + const v1 = { + topic: [b64(sym('announce')), b64(u32(1)), b64(addr(f.stealth))], + value: b64(xdr.ScVal.scvVec([addr(f.caller), bytes(f.eph), bytes(f.meta)])), + }; + const v2 = { + contractId: 'CANNOUNCER', + topic: [b64(sym('announce')), b64(u32(2)), b64(u32(7)), b64(u32(0))], + value: b64(xdr.ScVal.scvVec([addr(f.stealth), bytes(f.eph), bytes(f.meta)])), + }; + const result = decodeAnnouncementEvents([v1, { topic: ['bad'], value: 'bad' }, v2, {}]); + expect(result.map((a) => a.schemeId)).toEqual([1, 2]); + }); +}); diff --git a/src/lib/stellar/announcementEvent.ts b/src/lib/stellar/announcementEvent.ts new file mode 100644 index 0000000..ec3ff11 --- /dev/null +++ b/src/lib/stellar/announcementEvent.ts @@ -0,0 +1,67 @@ +import { bytesToHex } from '@wraith-protocol/sdk/chains/stellar'; +import type { Announcement } from '@wraith-protocol/sdk/chains/stellar'; +import { Address, xdr } from '@stellar/stellar-sdk'; + +/** + * Decodes one Announcer event from Soroban RPC `getEvents`; null if it isn't one. + * + * v1: topics [name, scheme, stealth_address] value [caller, ephemeral_pub, metadata] + * v2: topics [name, scheme, view_tag_bucket, ...] value [stealth_address, ephemeral_pub, metadata] + */ +export function parseAnnouncementEvent(event: Record): Announcement | null { + const topics = event.topic; + if (!Array.isArray(topics)) return null; + if (topics.length === 3) return parseV1(event, topics as string[]); + if (topics.length === 4) return parseV2(event, topics as string[]); + return null; +} + +function parseV1(event: Record, topics: string[]): Announcement | null { + const schemeId = xdr.ScVal.fromXDR(topics[1], 'base64').u32(); + const stealthScVal = xdr.ScVal.fromXDR(topics[2], 'base64'); + const stealthAddress = Address.fromScAddress(stealthScVal.address()).toString(); + + const valueVec = xdr.ScVal.fromXDR(event.value as string, 'base64').vec(); + if (!valueVec || valueVec.length < 3) return null; + + const caller = Address.fromScAddress(valueVec[0].address()).toString(); + const ephemeralPubKey = bytesToHex(new Uint8Array(valueVec[1].bytes())); + const metadata = bytesToHex(new Uint8Array(valueVec[2].bytes())); + + return { schemeId, stealthAddress, caller, ephemeralPubKey, metadata }; +} + +function parseV2(event: Record, topics: string[]): Announcement | null { + const schemeId = xdr.ScVal.fromXDR(topics[1], 'base64').u32(); + + const valueVec = xdr.ScVal.fromXDR(event.value as string, 'base64').vec(); + if (!valueVec || valueVec.length < 3) return null; + + const stealthAddress = Address.fromScAddress(valueVec[0].address()).toString(); + const ephemeralPubKey = bytesToHex(new Uint8Array(valueVec[1].bytes())); + const metadata = bytesToHex(new Uint8Array(valueVec[2].bytes())); + + // v2 events don't carry a caller; the SDK records the emitting contract instead. + const caller = + typeof event.contractId === 'string' + ? event.contractId + : typeof event.contract_id === 'string' + ? event.contract_id + : ''; + + return { schemeId, stealthAddress, caller, ephemeralPubKey, metadata }; +} + +/** Decodes one page of RPC events, skipping entries that are not (or cannot be decoded as) announcements. */ +export function decodeAnnouncementEvents(events: readonly object[]): Announcement[] { + const announcements: Announcement[] = []; + for (const event of events) { + try { + const announcement = parseAnnouncementEvent(event as Record); + if (announcement) announcements.push(announcement); + } catch { + // An undecodable event must not abort the page: the cursor still has to cover its neighbours. + } + } + return announcements; +} diff --git a/src/lib/stellar/rpcEvents.tip.test.ts b/src/lib/stellar/rpcEvents.tip.test.ts new file mode 100644 index 0000000..f14fac5 --- /dev/null +++ b/src/lib/stellar/rpcEvents.tip.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchEventPages } from './rpcEvents'; + +// Real cursor shape: the RPC's end-of-data cursor for ledger N, e.g. +// ledger 5256415 -> "0022576134814171135-4294967295" (seen on testnet). +const tip = (ledger: number) => + ((BigInt(ledger) << 32n) | 0xffffffffn).toString().padStart(19, '0') + '-4294967295'; + +type Body = { method: string; params?: { startLedger?: number } }; +const ev = (ledger: number) => ({ topic: ['AAAA'], value: 'AAAA', ledger }); +const page = (events: unknown[], cursor?: string, latestLedger = 5000) => ({ + result: { events, cursor, latestLedger }, +}); + +/** Answers the ledger-window probe, then serves pages in order. */ +function mockRpc(pages: unknown[]) { + let next = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as Body; + const isProbe = body.method === 'getEvents' && body.params?.startLedger === 1; + const json = isProbe + ? { + jsonrpc: '2.0', + id: 1, + error: { + code: -32602, + message: 'startLedger must be within the ledger range: 1000 - 5000', + }, + } + : pages[next++]; + return new Response(JSON.stringify(json)); + }), + ); +} +const scan = (onPage?: (p: { committedLedger: number }) => void) => + fetchEventPages({ + rpcUrl: 'https://rpc.test', + contractId: 'CCONTRACT', + startLedger: 4990, + onPage, + }); + +afterEach(() => vi.unstubAllGlobals()); + +describe('fetchEventPages at the tip of the ledger', () => { + it('finishes when an empty page carries the end-of-data cursor', async () => { + mockRpc([page([], tip(5000))]); + const committed: number[] = []; + const result = await scan((p) => committed.push(p.committedLedger)); + expect(result).toMatchObject({ pages: 1, committedLedger: 5000, nextLedger: 5001 }); + expect(committed).toEqual([5000]); + }); + + it('reads events, then finishes on the empty tip page', async () => { + mockRpc([page([ev(4995), ev(4996)], '0000000000000000001-0000000001'), page([], tip(5000))]); + const committed: number[] = []; + const result = await scan((p) => committed.push(p.committedLedger)); + expect(result.events).toHaveLength(2); + expect(result).toMatchObject({ pages: 2, committedLedger: 5000, nextLedger: 5001 }); + expect(committed).toEqual([4995, 5000]); + }); + + it('still rejects a repeated cursor on a page that returned events', async () => { + mockRpc([page([ev(4995)], 'c1'), page([ev(4995)], 'c1')]); + await expect(scan()).rejects.toThrow(/did not advance/); + }); + + it('still rejects an empty repeated cursor that is behind the latest ledger', async () => { + mockRpc([page([], tip(100)), page([], tip(100))]); + await expect(scan()).rejects.toThrow(/did not advance/); + }); +}); diff --git a/src/lib/stellar/rpcEvents.ts b/src/lib/stellar/rpcEvents.ts index 2504372..995afcf 100644 --- a/src/lib/stellar/rpcEvents.ts +++ b/src/lib/stellar/rpcEvents.ts @@ -173,6 +173,17 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +/** + * Ledger encoded in a Soroban event cursor ("<19-digit TOID>-"): the TOID's + * high 32 bits are the ledger sequence. Undefined for any other cursor shape. + */ +function cursorLedger(cursor: string): number | undefined { + const match = /^(\d{1,19})-\d+$/.exec(cursor); + if (!match) return undefined; + const ledger = BigInt(match[1]) >> 32n; + return ledger <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(ledger) : undefined; +} + function validateEvent(raw: unknown): StellarRpcEvent | undefined { if (!isRecord(raw)) return undefined; if (typeof raw.value !== 'string') return undefined; @@ -450,7 +461,17 @@ export async function fetchEventPages( undefined, ); - if (page.cursor === undefined) { + // The RPC returns a continuation cursor even on an empty page. At the tip that cursor + // means "read through ledger N" and is echoed unchanged until the next ledger closes. + // An empty page whose cursor has reached the latest ledger is therefore the end of + // the data, not a stalled cursor; every other repeated cursor still throws below. + const atTip = + page.cursor !== undefined && + page.events.length === 0 && + page.latestLedger !== undefined && + (cursorLedger(page.cursor) ?? -1) >= page.latestLedger; + + if (page.cursor === undefined || atTip) { // The window is exhausted: every ledger the RPC was able to serve has // been read, so the scan may commit up to the smaller of the two tips. committedLedger = Math.min(throughLedger, page.latestLedger ?? throughLedger); @@ -471,7 +492,7 @@ export async function fetchEventPages( committedLedger, }); - if (page.cursor === undefined) break; + if (page.cursor === undefined || atTip) break; if (committedLedger >= throughLedger) break; if (page.cursor === cursor) { throw new StellarRpcResponseError('getEvents', 'cursor did not advance between pages'); diff --git a/src/sw/accountRecords.test.ts b/src/sw/accountRecords.test.ts new file mode 100644 index 0000000..86ae21a --- /dev/null +++ b/src/sw/accountRecords.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { stripLegacySecretFields } from './accountRecords'; + +describe('stripLegacySecretFields', () => { + it('removes every legacy encrypted-key field and keeps cursor state', () => { + const { record, changed } = stripLegacySecretFields({ + publicKey: 'G1', + encryptedViewingKey: 'aa', + encryptedSpendingPubKey: 'bb', + encryptedSpendingScalar: 'cc', + lastScannedLedger: 42, + }); + expect(changed).toBe(true); + expect(record).toEqual({ publicKey: 'G1', lastScannedLedger: 42 }); + }); + + it('reports unchanged for clean records', () => { + expect(stripLegacySecretFields({ publicKey: 'G1' }).changed).toBe(false); + }); +}); diff --git a/src/sw/accountRecords.ts b/src/sw/accountRecords.ts new file mode 100644 index 0000000..911aa32 --- /dev/null +++ b/src/sw/accountRecords.ts @@ -0,0 +1,64 @@ +// Fields older builds wrote into the 'viewing-keys' store. None of them may survive. +const LEGACY_SECRET_FIELDS = [ + 'encryptedViewingKey', + 'encryptedSpendingPubKey', + 'encryptedSpendingScalar', +] as const; + +export function stripLegacySecretFields( + record: T, +): { record: T; changed: boolean } { + const copy = { ...record } as Record; + let changed = false; + for (const field of LEGACY_SECRET_FIELDS) { + if (field in copy) { + delete copy[field]; + changed = true; + } + } + return { record: copy as T, changed }; +} + +function finish(tx: IDBTransaction, resolve: () => void, reject: (e: unknown) => void) { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); +} + +/** Registers an account for scanning without secrets; keeps cursor + push data. */ +export function upsertAccountRecord( + db: IDBDatabase, + storeName: string, + publicKey: string, +): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction([storeName], 'readwrite'); + const store = tx.objectStore(storeName); + const read = store.get(publicKey); + read.onsuccess = () => { + const { record } = stripLegacySecretFields((read.result ?? {}) as object); + store.put({ ...record, publicKey, timestamp: Date.now() }); + }; + finish(tx, resolve, reject); + }); +} + +/** One-time cleanup of records written by older builds. Returns how many were changed. */ +export function scrubLegacySpendingMaterial(db: IDBDatabase, storeName: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction([storeName], 'readwrite'); + let scrubbed = 0; + const cursorRequest = tx.objectStore(storeName).openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) return; + const { record, changed } = stripLegacySecretFields(cursor.value as object); + if (changed) { + cursor.update(record); + scrubbed++; + } + cursor.continue(); + }; + finish(tx, () => resolve(scrubbed), reject); + }); +} diff --git a/src/sw/app-sw.ts b/src/sw/app-sw.ts index 543261d..0c6f652 100644 --- a/src/sw/app-sw.ts +++ b/src/sw/app-sw.ts @@ -344,6 +344,15 @@ self.addEventListener('notificationclick', (event) => { // ── Message handler ──────────────────────────────────────────────────────────── self.addEventListener('message', (event) => { + const forbidden = findSpendingSecretField(event.data); + if (forbidden) { + console.error('[app-sw] Dropped message: "' + forbidden + '" must never reach the worker'); + (event.source as Client)?.postMessage({ + type: 'VIEWING_KEY_ERROR', + error: 'Spending secrets are not accepted by the service worker', + }); + return; + } const validation = validateMainThreadMessage(event.data); if (!validation.valid) { console.warn('[app-sw] Ignoring invalid message from main thread:', validation.reason); @@ -621,3 +630,6 @@ self.addEventListener('push', (event: PushEvent) => { }); export {}; +function findSpendingSecretField(data: any) { + throw new Error('Function not implemented.'); +} diff --git a/src/sw/backgroundScan.test.ts b/src/sw/backgroundScan.test.ts new file mode 100644 index 0000000..399a12c --- /dev/null +++ b/src/sw/backgroundScan.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + bytesToHex, + deriveStealthKeys, + generateStealthAddress, + SCHEME_ID, +} from '@wraith-protocol/sdk/chains/stellar'; +import type { Announcement } from '@wraith-protocol/sdk/chains/stellar'; +import { + buildMatchNotification, + scanPages, + type BackgroundMatch, + type ScanPage, +} from './backgroundScan'; +import { + lockAllScanKeys, + lockScanKey, + removeScanKey, + sealScanKey, + toScanKeyMaterial, + type ScanKeyBackend, +} from './scanKeyStore'; +import { MemoryScanKeyBackend } from './testing/memoryScanKeyBackend'; + +const ACCOUNT = 'GTESTACCOUNT'; + +function randomSignature(): Uint8Array { + const bytes = new Uint8Array(64); + crypto.getRandomValues(bytes); + return bytes; +} + +function makeAnnouncement(spendingPubKey: Uint8Array, viewingPubKey: Uint8Array): Announcement { + const generated = generateStealthAddress(spendingPubKey, viewingPubKey); + return { + schemeId: SCHEME_ID, + stealthAddress: generated.stealthAddress, + caller: 'GARBAGECALLERADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + ephemeralPubKey: bytesToHex(generated.ephemeralPubKey), + metadata: bytesToHex(new Uint8Array([generated.viewTag])), + }; +} + +function setup() { + const recipient = deriveStealthKeys(randomSignature()); + const stranger = deriveStealthKeys(randomSignature()); + const mine = makeAnnouncement(recipient.spendingPubKey, recipient.viewingPubKey); + const mine2 = makeAnnouncement(recipient.spendingPubKey, recipient.viewingPubKey); + const noise = makeAnnouncement(stranger.spendingPubKey, stranger.viewingPubKey); + const notified: BackgroundMatch[] = []; + const commits: number[] = []; + const pages: ScanPage[] = [ + { announcements: [noise, mine], committedLedger: 100 }, + { announcements: [mine2], committedLedger: 200 }, + ]; + const readPages = vi.fn(async (onPage: (page: ScanPage) => Promise) => { + for (const page of pages) await onPage(page); + }); + const run = ( + backend: ScanKeyBackend, + opts: { publicKey?: string; onCommit?: () => Promise } = {}, + ) => + scanPages({ + backend, + publicKey: opts.publicKey ?? ACCOUNT, + notifyMatch: async (m) => { + notified.push(m); + }, + readPages, + commit: async (next) => { + commits.push(next); + await opts.onCommit?.(); + }, + }); + const registered = async () => { + const backend = new MemoryScanKeyBackend(); + await sealScanKey(backend, ACCOUNT, toScanKeyMaterial(recipient)); + return backend; + }; + const addresses = () => notified.map((m) => m.stealthAddress); + return { recipient, mine, mine2, notified, commits, readPages, run, registered, addresses }; +} + +describe('background page scanning', () => { + it('notifies matches page by page, commits each page after it, and leaks no secrets', async () => { + const { recipient, mine, mine2, commits, notified, run, registered, addresses } = setup(); + const backend = await registered(); + + expect(await run(backend)).toEqual({ status: 'scanned' }); + + expect(addresses()).toEqual([mine.stealthAddress, mine2.stealthAddress]); + expect(commits).toEqual([101, 201]); + + const wire = JSON.stringify([notified, commits, buildMatchNotification(notified[0])]); + expect(wire).not.toContain(recipient.spendingScalar.toString(16)); + expect(wire).not.toContain(recipient.spendingScalar.toString()); + expect(wire).not.toContain(bytesToHex(recipient.viewingKey)); + expect(backend.dump()).not.toContain(recipient.spendingScalar.toString()); + }); + + it('lock: reads nothing, commits nothing and notifies nothing; unlocking resumes', async () => { + const { recipient, notified, commits, readPages, run, registered } = setup(); + const backend = await registered(); + + await lockScanKey(backend, ACCOUNT); + expect(await run(backend)).toEqual({ status: 'locked' }); + expect(readPages).not.toHaveBeenCalled(); + expect(commits).toEqual([]); + expect(notified).toHaveLength(0); + + await sealScanKey(backend, ACCOUNT, toScanKeyMaterial(recipient)); // unlock + expect((await run(backend)).status).toBe('scanned'); + expect(commits).toEqual([101, 201]); + }); + + it('lock-all locks every account', async () => { + const { run, registered } = setup(); + const backend = await registered(); + await lockAllScanKeys(backend); + expect((await run(backend)).status).toBe('locked'); + }); + + it('a lock between pages stops the scan without committing the unread page', async () => { + const { commits, run, registered, addresses, mine } = setup(); + const backend = await registered(); + + const result = await run(backend, { onCommit: () => lockScanKey(backend, ACCOUNT) }); + + expect(result).toEqual({ status: 'locked' }); + expect(addresses()).toEqual([mine.stealthAddress]); // page 2 was never matched + expect(commits).toEqual([101]); // the cursor stops before page 2, so it is re-read later + }); + + it('restart: a fresh worker resumes from persisted state alone', async () => { + const { run, registered, commits } = setup(); + const backend = await registered(); + + expect((await run(backend.restart())).status).toBe('scanned'); + expect(commits).toEqual([101, 201]); + }); + + it('restart: a locked key stays locked', async () => { + const { run, registered } = setup(); + const backend = await registered(); + await lockScanKey(backend, ACCOUNT); + + expect((await run(backend.restart())).status).toBe('locked'); + }); + + it('key removal wipes everything and the worker goes idle', async () => { + const { readPages, notified, commits, run, registered } = setup(); + const backend = await registered(); + + await removeScanKey(backend, ACCOUNT); + + expect(backend.records.size).toBe(0); + expect(backend.keys.size).toBe(0); + expect(await run(backend.restart())).toEqual({ status: 'missing' }); + expect(readPages).not.toHaveBeenCalled(); + expect(notified).toHaveLength(0); + expect(commits).toEqual([]); + }); + + it('a sealed blob cannot be reused for another account', async () => { + const { run, registered } = setup(); + const backend = await registered(); + backend.records.set('GOTHER', { ...backend.records.get(ACCOUNT)!, publicKey: 'GOTHER' }); + backend.keys.set('GOTHER', backend.keys.get(ACCOUNT)!); + + expect((await run(backend, { publicKey: 'GOTHER' })).status).toBe('invalid'); + }); +}); diff --git a/src/sw/backgroundScan.ts b/src/sw/backgroundScan.ts new file mode 100644 index 0000000..ff18d5a --- /dev/null +++ b/src/sw/backgroundScan.ts @@ -0,0 +1,164 @@ +import type { Announcement } from '@wraith-protocol/sdk/chains/stellar'; +import { DEFAULT_SCAN_STRATEGY, type ScanStrategy } from '../workers/stellarScanDispatch'; +import { findMatches } from '../workers/stellarScanMatch'; +import { + isScanKeyUnlocked, + openScanKey, + wipeScanKeyMaterial, + type ScanKeyBackend, +} from './scanKeyStore'; + +/** What leaves the scanner on a match: no keys, no scalars. */ +export interface BackgroundMatch { + publicKey: string; + stealthAddress: string; +} + +export interface PageScannerDeps { + backend: ScanKeyBackend; + publicKey: string; + notifyMatch: (match: BackgroundMatch) => Promise; + strategy?: ScanStrategy; +} + +export interface PageScanner { + /** + * Matches one page and notifies. Resolves true when the page was handled, false when + * the key was locked or removed first. On false the caller must NOT advance its durable + * scan cursor past this page. + */ + scanPage(announcements: Announcement[]): Promise; + /** Wipes the in-memory key material. Always call when done. */ + close(): void; +} + +export type OpenPageScannerResult = + | { status: 'ready'; scanner: PageScanner } + | { status: 'locked' | 'missing' | 'invalid' }; + +/** + * Opens the sealed scan key. Holds no state beyond the returned scanner: lock, restart + * and removal all take effect because the key is re-read from storage on every open. + */ +export async function openPageScanner(deps: PageScannerDeps): Promise { + const opened = await openScanKey(deps.backend, deps.publicKey); + if (opened.status !== 'ok') return { status: opened.status }; + const { material } = opened; + let closed = false; + + return { + status: 'ready', + scanner: { + async scanPage(announcements) { + if (closed) return false; + // The key may have been locked or removed since the previous page. + if (!(await isScanKeyUnlocked(deps.backend, deps.publicKey))) return false; + const found = findMatches( + deps.strategy ?? DEFAULT_SCAN_STRATEGY, + announcements, + material.viewingKey, + material.spendingPubKey, + ); + for (const match of found) { + await deps.notifyMatch({ + publicKey: deps.publicKey, + stealthAddress: match.stealthAddress, + }); + } + return true; + }, + close() { + if (closed) return; + closed = true; + wipeScanKeyMaterial(material); + }, + }, + }; +} + +export interface ScanPage { + announcements: Announcement[]; + /** Highest ledger proven fully read; the cursor to persist is committedLedger + 1. */ + committedLedger: number; +} + +export type ScanPagesResult = { status: 'scanned' } | { status: 'locked' | 'missing' | 'invalid' }; + +class ScanKeyUnavailableError extends Error { + constructor() { + super('Scan key was locked or removed during the scan'); + this.name = 'ScanKeyUnavailableError'; + } +} + +/** + * One background scan. The key is opened first, so a locked or unregistered account costs + * no network. Each page is matched and notified BEFORE its cursor is committed, so a + * payment can never be skipped by the cursor; a lock between pages stops the scan without + * committing the unread page. Notification is at-least-once: a failure after notifying but + * before committing re-notifies on the next scan (notifications are tagged per address). + */ +export async function scanPages( + deps: PageScannerDeps & { + readPages: (onPage: (page: ScanPage) => Promise) => Promise; + commit: (nextLedger: number) => Promise; + }, +): Promise { + const opened = await openPageScanner(deps); + if (opened.status !== 'ready') return { status: opened.status }; + const { scanner } = opened; + + try { + await deps.readPages(async (page) => { + if (!(await scanner.scanPage(page.announcements))) throw new ScanKeyUnavailableError(); + await deps.commit(page.committedLedger + 1); + }); + return { status: 'scanned' }; + } catch (error) { + if (error instanceof ScanKeyUnavailableError) return { status: 'locked' }; + throw error; + } finally { + scanner.close(); + } +} + +// ─── notification ──────────────────────────────────────────────────────────── + +export interface MatchNotificationOptions { + body: string; + icon: string; + badge: string; + tag: string; + data: { id: string; stealthAddress: string; timestamp: number; url: string }; +} + +export interface MatchNotifier { + showNotification(title: string, options: MatchNotificationOptions): Promise; + broadcast(message: unknown): Promise; +} + +export function buildMatchNotification(match: BackgroundMatch, now = Date.now()) { + const id = `match:${match.stealthAddress}`; + const options: MatchNotificationOptions = { + body: 'A new stealth payment matched your account. Open Wraith to claim it.', + icon: '/favicon-32x32.png', + badge: '/favicon-16x16.png', + tag: id, + data: { id, stealthAddress: match.stealthAddress, timestamp: now, url: '/notifications' }, + }; + return { title: 'Payment received', options }; +} + +export async function deliverMatch(notifier: MatchNotifier, match: BackgroundMatch) { + const { title, options } = buildMatchNotification(match); + try { + await notifier.showNotification(title, options); + } catch (error) { + console.warn('[wraith-sw] Could not show match notification:', error); + } + await notifier.broadcast({ + type: 'STELLAR_PAYMENT_MATCH', + publicKey: match.publicKey, + stealthAddress: match.stealthAddress, + }); +} diff --git a/src/sw/idbScanKeyBackend.ts b/src/sw/idbScanKeyBackend.ts new file mode 100644 index 0000000..1e5c8e3 --- /dev/null +++ b/src/sw/idbScanKeyBackend.ts @@ -0,0 +1,89 @@ +import type { ScanKeyBackend, SealedScanKey } from './scanKeyStore'; + +// Dedicated DB so page and both service workers share one schema/version. +const DB_NAME = 'wraith-scan-keys'; +const DB_VERSION = 1; +const SEALED_STORE = 'sealed-keys'; +const WRAPPING_STORE = 'wrapping-keys'; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(SEALED_STORE)) { + db.createObjectStore(SEALED_STORE, { keyPath: 'publicKey' }); + } + if (!db.objectStoreNames.contains(WRAPPING_STORE)) db.createObjectStore(WRAPPING_STORE); + }; + }); +} + +function done(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +function result(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function withDb(work: (db: IDBDatabase) => Promise): Promise { + const db = await openDb(); + try { + return await work(db); + } finally { + db.close(); + } +} + +export function createIdbScanKeyBackend(): ScanKeyBackend { + return { + getRecord: (publicKey) => + withDb((db) => + result( + db.transaction(SEALED_STORE).objectStore(SEALED_STORE).get(publicKey), + ), + ), + getWrappingKey: (publicKey) => + withDb((db) => + result( + db.transaction(WRAPPING_STORE).objectStore(WRAPPING_STORE).get(publicKey), + ), + ), + putSealed: (record, wrappingKey) => + withDb(async (db) => { + const tx = db.transaction([SEALED_STORE, WRAPPING_STORE], 'readwrite'); + tx.objectStore(SEALED_STORE).put(record); + tx.objectStore(WRAPPING_STORE).put(wrappingKey, record.publicKey); + await done(tx); + }), + deleteWrappingKey: (publicKey) => + withDb(async (db) => { + const tx = db.transaction(WRAPPING_STORE, 'readwrite'); + tx.objectStore(WRAPPING_STORE).delete(publicKey); + await done(tx); + }), + clearWrappingKeys: () => + withDb(async (db) => { + const tx = db.transaction(WRAPPING_STORE, 'readwrite'); + tx.objectStore(WRAPPING_STORE).clear(); + await done(tx); + }), + remove: (publicKey) => + withDb(async (db) => { + const tx = db.transaction([SEALED_STORE, WRAPPING_STORE], 'readwrite'); + tx.objectStore(SEALED_STORE).delete(publicKey); + tx.objectStore(WRAPPING_STORE).delete(publicKey); + await done(tx); + }), + }; +} diff --git a/src/sw/noSpendingScalar.test.ts b/src/sw/noSpendingScalar.test.ts new file mode 100644 index 0000000..fd70b96 --- /dev/null +++ b/src/sw/noSpendingScalar.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const WORKER_FACING_FILES = [ + 'src/sw/app-sw.ts', + 'src/hooks/useStellarNotifications.ts', + 'src/sw/backgroundScan.ts', + 'src/sw/scanKeyStore.ts', + 'src/sw/idbScanKeyBackend.ts', + 'src/workers/stellarScanMatch.ts', +]; + +describe('spending secrets stay out of worker-facing code', () => { + it.each(WORKER_FACING_FILES)('%s never mentions a spending scalar', (file) => { + const source = readFileSync(path.resolve(process.cwd(), file), 'utf8'); + expect(source).not.toMatch(/spendingScalar/i); + }); +}); diff --git a/src/sw/scanKeyLifecycle.ts b/src/sw/scanKeyLifecycle.ts new file mode 100644 index 0000000..34f41bd --- /dev/null +++ b/src/sw/scanKeyLifecycle.ts @@ -0,0 +1,16 @@ +import { createIdbScanKeyBackend } from './idbScanKeyBackend'; +import { lockAllScanKeys, type ScanKeyBackend } from './scanKeyStore'; + +/** + * Locks every account's background-scan key. Called whenever the Stellar stealth + * keys leave memory (wallet disconnect, app idle lock, leaving recovery mode). + * Never rejects: a lock must not fail loudly or block the UI. + */ +export async function lockStellarScanKeys(backend?: ScanKeyBackend): Promise { + if (!backend && typeof indexedDB === 'undefined') return; + try { + await lockAllScanKeys(backend ?? createIdbScanKeyBackend()); + } catch (error) { + console.error('[scan-keys] Failed to lock scan keys:', error); + } +} diff --git a/src/sw/scanKeyStore.test.ts b/src/sw/scanKeyStore.test.ts new file mode 100644 index 0000000..6184b62 --- /dev/null +++ b/src/sw/scanKeyStore.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { deriveStealthKeys } from '@wraith-protocol/sdk/chains/stellar'; +import { + findSpendingSecretField, + openScanKey, + sealScanKey, + toScanKeyMaterial, +} from './scanKeyStore'; +import { MemoryScanKeyBackend } from './testing/memoryScanKeyBackend'; + +function randomSignature(): Uint8Array { + const bytes = new Uint8Array(64); + crypto.getRandomValues(bytes); + return bytes; +} + +describe('scan key store', () => { + it('seals exactly the viewing key and the spending public key', async () => { + const keys = deriveStealthKeys(randomSignature()); + const backend = new MemoryScanKeyBackend(); + await sealScanKey(backend, 'GACCOUNT', toScanKeyMaterial(keys)); + + const record = backend.records.get('GACCOUNT')!; + // 2 length bytes + two keys + 16-byte GCM tag: there is no room for anything else. + expect(record.ciphertext.length).toBe( + 2 + keys.viewingKey.length + keys.spendingPubKey.length + 16, + ); + expect(backend.keys.get('GACCOUNT')!.extractable).toBe(false); + + const opened = await openScanKey(backend, 'GACCOUNT'); + expect(opened.status).toBe('ok'); + if (opened.status === 'ok') { + expect(opened.material.viewingKey).toEqual(keys.viewingKey); + expect(opened.material.spendingPubKey).toEqual(keys.spendingPubKey); + } + }); + + it('toScanKeyMaterial drops every other field of the key bundle', () => { + const material = toScanKeyMaterial(deriveStealthKeys(randomSignature())); + expect(Object.keys(material).sort()).toEqual(['spendingPubKey', 'viewingKey']); + }); +}); + +describe('findSpendingSecretField', () => { + it('flags spending secrets however they are named or nested', () => { + expect(findSpendingSecretField({ type: 'X', encryptedSpendingScalar: 'aa' })).toBe( + 'encryptedSpendingScalar', + ); + expect(findSpendingSecretField({ a: { b: [{ spendingScalar: 1n }] } })).toBe( + 'a.b.0.spendingScalar', + ); + expect(findSpendingSecretField({ stealthPrivateScalar: 1n })).toBe('stealthPrivateScalar'); + }); + + it('allows public keys and ordinary messages', () => { + expect( + findSpendingSecretField({ + type: 'REGISTER_VIEWING_KEY', + publicKey: 'G...', + spendingPubKey: new Uint8Array(32), + encryptedSpendingPubKey: 'aa', + }), + ).toBeNull(); + expect(findSpendingSecretField(null)).toBeNull(); + }); +}); diff --git a/src/sw/scanKeyStore.ts b/src/sw/scanKeyStore.ts new file mode 100644 index 0000000..24b93fa --- /dev/null +++ b/src/sw/scanKeyStore.ts @@ -0,0 +1,185 @@ +/** + * Minimum key material a background scanner may hold: the viewing key and the + * spending PUBLIC key. A spending secret has no field here by design. + */ +export interface ScanKeyMaterial { + viewingKey: Uint8Array; + spendingPubKey: Uint8Array; +} + +export interface SealedScanKey { + publicKey: string; + version: 1; + iv: Uint8Array; + ciphertext: Uint8Array; + sealedAt: number; +} + +/** Storage seam: IndexedDB in the browser, a Map in tests. */ +export interface ScanKeyBackend { + getRecord(publicKey: string): Promise; + getWrappingKey(publicKey: string): Promise; + /** Writes the sealed record and its wrapping key atomically. */ + putSealed(record: SealedScanKey, wrappingKey: CryptoKey): Promise; + deleteWrappingKey(publicKey: string): Promise; + clearWrappingKeys(): Promise; + remove(publicKey: string): Promise; +} + +export type OpenScanKeyResult = + | { status: 'ok'; material: ScanKeyMaterial } + | { status: 'locked' } // sealed record exists, wrapping key is gone + | { status: 'missing' } // nothing registered + | { status: 'invalid' }; // corrupt, or sealed for another account + +const asBuffer = (bytes: Uint8Array) => bytes as unknown as BufferSource; +const aadFor = (publicKey: string) => new TextEncoder().encode(`wraith-scan-key:v1:${publicKey}`); + +function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex)) throw new Error('Invalid hex key'); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; +} + +/** Copies ONLY the two permitted fields; anything else on the object is dropped. */ +export function toScanKeyMaterial(keys: { + viewingKey: Uint8Array | string; + spendingPubKey: Uint8Array; +}): ScanKeyMaterial { + return { + viewingKey: + typeof keys.viewingKey === 'string' + ? hexToBytes(keys.viewingKey) + : new Uint8Array(keys.viewingKey), + spendingPubKey: new Uint8Array(keys.spendingPubKey), + }; +} + +export function wipeScanKeyMaterial(material: ScanKeyMaterial): void { + material.viewingKey.fill(0); + material.spendingPubKey.fill(0); +} + +function pack(m: ScanKeyMaterial): Uint8Array { + const { viewingKey: v, spendingPubKey: s } = m; + if (v.length === 0 || v.length > 255 || s.length === 0 || s.length > 255) { + throw new Error('Invalid scan key material'); + } + const out = new Uint8Array(2 + v.length + s.length); + out[0] = v.length; + out[1] = s.length; + out.set(v, 2); + out.set(s, 2 + v.length); + return out; +} + +function unpack(bytes: Uint8Array): ScanKeyMaterial { + const vLen = bytes[0]; + const sLen = bytes[1]; + if (bytes.length !== 2 + vLen + sLen) throw new Error('Malformed scan key payload'); + return { + viewingKey: bytes.slice(2, 2 + vLen), + spendingPubKey: bytes.slice(2 + vLen, 2 + vLen + sLen), + }; +} + +/** Encrypts the material under a fresh non-extractable AES-GCM key and persists both. */ +export async function sealScanKey( + backend: ScanKeyBackend, + publicKey: string, + material: ScanKeyMaterial, +): Promise { + const plaintext = pack(material); + try { + const wrappingKey = await globalThis.crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: asBuffer(iv), additionalData: asBuffer(aadFor(publicKey)) }, + wrappingKey, + asBuffer(plaintext), + ); + await backend.putSealed( + { publicKey, version: 1, iv, ciphertext: new Uint8Array(ciphertext), sealedAt: Date.now() }, + wrappingKey, + ); + } finally { + plaintext.fill(0); + } +} + +export async function openScanKey( + backend: ScanKeyBackend, + publicKey: string, +): Promise { + const record = await backend.getRecord(publicKey); + if (!record) return { status: 'missing' }; + const wrappingKey = await backend.getWrappingKey(publicKey); + if (!wrappingKey) return { status: 'locked' }; + try { + const plaintext = new Uint8Array( + await globalThis.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: asBuffer(record.iv), + additionalData: asBuffer(aadFor(publicKey)), + }, + wrappingKey, + asBuffer(record.ciphertext), + ), + ); + try { + return { status: 'ok', material: unpack(plaintext) }; + } finally { + plaintext.fill(0); + } + } catch { + return { status: 'invalid' }; + } +} + +export async function isScanKeyUnlocked( + backend: ScanKeyBackend, + publicKey: string, +): Promise { + return ( + (await backend.getRecord(publicKey)) !== undefined && + (await backend.getWrappingKey(publicKey)) !== undefined + ); +} + +export async function hasScanKey(backend: ScanKeyBackend, publicKey: string): Promise { + return (await backend.getRecord(publicKey)) !== undefined; +} + +/** Lock: the worker can no longer decrypt. Unlock = seal again from the page. */ +export const lockScanKey = (backend: ScanKeyBackend, publicKey: string) => + backend.deleteWrappingKey(publicKey); +export const lockAllScanKeys = (backend: ScanKeyBackend) => backend.clearWrappingKeys(); +/** Key removal: wipes the sealed blob and the wrapping key. */ +export const removeScanKey = (backend: ScanKeyBackend, publicKey: string) => + backend.remove(publicKey); + +const SPENDING_SECRET_NAME = /spending(?!pub)|stealthPrivate/i; + +/** + * Returns the path of the first field that looks like a spending secret + * (spending scalar/key/seed or a stealth private scalar), or null. + * Workers use it to refuse any message carrying one. + */ +export function findSpendingSecretField(value: unknown, path = '', depth = 0): string | null { + if (depth > 6 || value === null || typeof value !== 'object' || ArrayBuffer.isView(value)) { + return null; + } + for (const [key, child] of Object.entries(value)) { + const here = path ? `${path}.${key}` : key; + if (SPENDING_SECRET_NAME.test(key)) return here; + const nested = findSpendingSecretField(child, here, depth + 1); + if (nested) return nested; + } + return null; +} diff --git a/src/sw/testing/memoryScanKeyBackend.ts b/src/sw/testing/memoryScanKeyBackend.ts new file mode 100644 index 0000000..1fa3ed6 --- /dev/null +++ b/src/sw/testing/memoryScanKeyBackend.ts @@ -0,0 +1,52 @@ +import type { ScanKeyBackend, SealedScanKey } from '../scanKeyStore'; + +export class MemoryScanKeyBackend implements ScanKeyBackend { + readonly records: Map; + readonly keys: Map; + + constructor(state?: { records: Map; keys: Map }) { + this.records = new Map(state?.records); + this.keys = new Map(state?.keys); + } + + /** Simulates a worker restart: only persisted data survives, as fresh copies. */ + restart(): MemoryScanKeyBackend { + const records = new Map(); + for (const [id, r] of this.records) { + records.set(id, { ...r, iv: new Uint8Array(r.iv), ciphertext: new Uint8Array(r.ciphertext) }); + } + return new MemoryScanKeyBackend({ records, keys: this.keys }); + } + + /** Everything that would be written to disk, for leak assertions. */ + dump(): string { + return JSON.stringify( + [...this.records.values()].map((r) => ({ + ...r, + iv: Array.from(r.iv), + ciphertext: Array.from(r.ciphertext), + })), + ); + } + + async getRecord(id: string) { + return this.records.get(id); + } + async getWrappingKey(id: string) { + return this.keys.get(id); + } + async putSealed(record: SealedScanKey, key: CryptoKey) { + this.records.set(record.publicKey, record); + this.keys.set(record.publicKey, key); + } + async deleteWrappingKey(id: string) { + this.keys.delete(id); + } + async clearWrappingKeys() { + this.keys.clear(); + } + async remove(id: string) { + this.records.delete(id); + this.keys.delete(id); + } +} diff --git a/src/workers/stellarScanMatch.test.ts b/src/workers/stellarScanMatch.test.ts new file mode 100644 index 0000000..c1d39b8 --- /dev/null +++ b/src/workers/stellarScanMatch.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { + bytesToHex, + deriveStealthKeys, + generateStealthAddress, + SCHEME_ID, +} from '@wraith-protocol/sdk/chains/stellar'; +import type { Announcement } from '@wraith-protocol/sdk/chains/stellar'; +import { scanWithStrategy, type ScanStrategy } from './stellarScanDispatch'; +import { findMatches } from './stellarScanMatch'; + +function randomSignature(): Uint8Array { + const bytes = new Uint8Array(64); + crypto.getRandomValues(bytes); + return bytes; +} + +function makeAnnouncement(spendingPubKey: Uint8Array, viewingPubKey: Uint8Array): Announcement { + const generated = generateStealthAddress(spendingPubKey, viewingPubKey); + return { + schemeId: SCHEME_ID, + stealthAddress: generated.stealthAddress, + caller: 'GARBAGECALLERADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + ephemeralPubKey: bytesToHex(generated.ephemeralPubKey), + metadata: bytesToHex(new Uint8Array([generated.viewTag])), + }; +} + +describe('findMatches', () => { + const recipient = deriveStealthKeys(randomSignature()); + const stranger = deriveStealthKeys(randomSignature()); + const mine = makeAnnouncement(recipient.spendingPubKey, recipient.viewingPubKey); + const noise = makeAnnouncement(stranger.spendingPubKey, stranger.viewingPubKey); + + it.each(['fast', 'balanced', 'full'] as ScanStrategy[])( + '%s finds the same payments as scanWithStrategy', + (strategy) => { + const expected = scanWithStrategy( + strategy, + [noise, mine], + recipient.viewingKey, + recipient.spendingPubKey, + recipient.spendingScalar, + ).map((m) => m.stealthAddress); + const actual = findMatches( + strategy, + [noise, mine], + recipient.viewingKey, + recipient.spendingPubKey, + ).map((m) => m.stealthAddress); + + expect(actual).toEqual(expected); + expect(actual).toEqual([mine.stealthAddress]); + }, + ); + + it('full still finds a payment with a corrupted tag; fast and balanced skip it', () => { + const corrupted: Announcement = { + ...mine, + metadata: bytesToHex(new Uint8Array([(parseInt(mine.metadata, 16) + 1) % 256])), + }; + const run = (s: ScanStrategy) => + findMatches(s, [corrupted], recipient.viewingKey, recipient.spendingPubKey); + + expect(run('fast')).toHaveLength(0); + expect(run('balanced')).toHaveLength(0); + expect(run('full')).toHaveLength(1); + }); + + it('never carries a private scalar', () => { + const matches = findMatches('balanced', [mine], recipient.viewingKey, recipient.spendingPubKey); + expect(Object.keys(matches[0]).sort()).toEqual(['announcement', 'stealthAddress']); + expect(() => JSON.stringify(matches)).not.toThrow(); // a bigint scalar would throw here + }); + + it('skips malformed announcements instead of throwing', () => { + const bad: Announcement = { ...mine, ephemeralPubKey: 'not-hex' }; + expect(findMatches('balanced', [bad], recipient.viewingKey, recipient.spendingPubKey)).toEqual( + [], + ); + }); +}); diff --git a/src/workers/stellarScanMatch.ts b/src/workers/stellarScanMatch.ts new file mode 100644 index 0000000..8d824c3 --- /dev/null +++ b/src/workers/stellarScanMatch.ts @@ -0,0 +1,122 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; +import { + checkStealthAddress, + computeSharedSecret, + deriveStealthPubKey, + hashToScalar, + hexToBytes, + pubKeyToStellarAddress, + SCHEME_ID, +} from '@wraith-protocol/sdk/chains/stellar'; +import type { Announcement } from '@wraith-protocol/sdk/chains/stellar'; +import type { ScanStrategy } from './stellarScanDispatch'; + +/** Scheme id of the v2 announcer (SDK source: src/chains/stellar/constants.ts). */ +export const SCHEME_ID_V2 = 2; + +const V2_VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:stellar:view-tag:v2:'); + +/** + * v2 view tag, derived from public announcement data only: + * SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0] + * Mirrors computeAnnouncementViewTag in the SDK source; the released 1.4.5 + * package does not export it. + */ +export function computeV2ViewTag(ephemeralPubKey: Uint8Array, viewingPubKey: Uint8Array): number { + const input = new Uint8Array( + V2_VIEW_TAG_PREFIX.length + ephemeralPubKey.length + viewingPubKey.length, + ); + input.set(V2_VIEW_TAG_PREFIX); + input.set(ephemeralPubKey, V2_VIEW_TAG_PREFIX.length); + input.set(viewingPubKey, V2_VIEW_TAG_PREFIX.length + ephemeralPubKey.length); + return sha256(input)[0]; +} + +/** A detected payment. Deliberately has no private scalar: claiming happens on the main thread. */ +export interface AnnouncementMatch { + announcement: Announcement; + stealthAddress: string; +} + +function deriveStealthAddress( + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, + ephPubKey: Uint8Array, +): string { + const hashScalar = hashToScalar(computeSharedSecret(viewingKey, ephPubKey)); + return pubKeyToStellarAddress(deriveStealthPubKey(spendingPubKey, hashScalar)); +} + +/** + * Same strategies as scanWithStrategy, for schemes 1 and 2, but needs only the + * viewing key and the spending PUBLIC key, so it can run where spend authority + * must never exist. + * + * - scheme 1: legacy shared-secret view tag (what released SDK 1.4.5 emits) + * - scheme 2: public view tag from computeV2ViewTag + */ +export function findMatches( + strategy: ScanStrategy, + announcements: Announcement[], + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, +): AnnouncementMatch[] { + const matches: AnnouncementMatch[] = []; + const found = (announcement: Announcement) => + matches.push({ announcement, stealthAddress: announcement.stealthAddress }); + + // Computed up front so an unusable viewing key fails the scan loudly instead of + // silently reporting zero matches. + const viewingPubKey = announcements.some((a) => a.schemeId === SCHEME_ID_V2) + ? ed25519.getPublicKey(viewingKey) + : undefined; + + for (const announcement of announcements) { + try { + const isV2 = announcement.schemeId === SCHEME_ID_V2; + if (announcement.schemeId !== SCHEME_ID && !isV2) continue; + + const ephPubKey = hexToBytes(announcement.ephemeralPubKey); + if (ephPubKey.length !== 32) continue; + + if (strategy === 'full') { + const address = deriveStealthAddress(viewingKey, spendingPubKey, ephPubKey); + if (address === announcement.stealthAddress) found(announcement); + continue; + } + + const metadata = hexToBytes(announcement.metadata); + if (metadata.length === 0) continue; + const viewTag = metadata[0]; + + if (isV2) { + if (!viewingPubKey || computeV2ViewTag(ephPubKey, viewingPubKey) !== viewTag) continue; + // balanced (and any unknown value) also cross-checks the derived address + if ( + strategy !== 'fast' && + deriveStealthAddress(viewingKey, spendingPubKey, ephPubKey) !== + announcement.stealthAddress + ) { + continue; + } + found(announcement); + continue; + } + + const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag); + if (!result.isMatch || result.stealthPubKeyBytes === null) continue; + if ( + strategy !== 'fast' && + pubKeyToStellarAddress(result.stealthPubKeyBytes) !== announcement.stealthAddress + ) { + continue; + } + found(announcement); + } catch { + // A malformed announcement must not abort the whole background scan. + } + } + + return matches; +} diff --git a/src/workers/stellarScanMatch.v2.test.ts b/src/workers/stellarScanMatch.v2.test.ts new file mode 100644 index 0000000..44664eb --- /dev/null +++ b/src/workers/stellarScanMatch.v2.test.ts @@ -0,0 +1,108 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; +import { describe, expect, it } from 'vitest'; +import { + bytesToHex, + deriveStealthKeys, + generateStealthAddress, + SCHEME_ID, +} from '@wraith-protocol/sdk/chains/stellar'; +import type { Announcement } from '@wraith-protocol/sdk/chains/stellar'; +import type { ScanStrategy } from './stellarScanDispatch'; +import { findMatches, SCHEME_ID_V2 } from './stellarScanMatch'; + +function randomSignature(): Uint8Array { + const bytes = new Uint8Array(64); + crypto.getRandomValues(bytes); + return bytes; +} + +// Independent re-statement of the spec, so the fixture does not depend on the code under test. +function specV2Tag(ephemeralPubKey: Uint8Array, viewingPubKey: Uint8Array): number { + const prefix = new TextEncoder().encode('wraith:stellar:view-tag:v2:'); + const input = new Uint8Array(prefix.length + ephemeralPubKey.length + viewingPubKey.length); + input.set(prefix); + input.set(ephemeralPubKey, prefix.length); + input.set(viewingPubKey, prefix.length + ephemeralPubKey.length); + return sha256(input)[0]; +} + +/** Scheme-2 announcement whose public tag differs from the legacy tag. */ +function makeV2Announcement(spendingPubKey: Uint8Array, viewingPubKey: Uint8Array): Announcement { + for (let attempt = 0; attempt < 20; attempt++) { + const generated = generateStealthAddress(spendingPubKey, viewingPubKey); + const v2Tag = specV2Tag(generated.ephemeralPubKey, viewingPubKey); + if (v2Tag === generated.viewTag) continue; + return { + schemeId: SCHEME_ID_V2, + stealthAddress: generated.stealthAddress, + caller: 'CANNOUNCERCONTRACT', + ephemeralPubKey: bytesToHex(generated.ephemeralPubKey), + metadata: bytesToHex(new Uint8Array([v2Tag])), + }; + } + throw new Error('could not build a distinguishable scheme-2 fixture'); +} + +const STRATEGIES: ScanStrategy[] = ['fast', 'balanced', 'full']; + +describe('findMatches: scheme 2 (v2 announcer)', () => { + const recipient = deriveStealthKeys(randomSignature()); + const stranger = deriveStealthKeys(randomSignature()); + const mine = makeV2Announcement(recipient.spendingPubKey, recipient.viewingPubKey); + const noise = makeV2Announcement(stranger.spendingPubKey, stranger.viewingPubKey); + const run = (strategy: ScanStrategy, announcements: Announcement[]) => + findMatches(strategy, announcements, recipient.viewingKey, recipient.spendingPubKey).map( + (m) => m.stealthAddress, + ); + + it('fixture sanity: the viewing public key is the ed25519 key of the viewing seed', () => { + expect(ed25519.getPublicKey(recipient.viewingKey)).toEqual(recipient.viewingPubKey); + }); + + it.each(STRATEGIES)('%s finds a scheme-2 payment and ignores a stranger', (strategy) => { + expect(run(strategy, [noise, mine])).toEqual([mine.stealthAddress]); + }); + + it('is non-vacuous: the same bytes labelled scheme 1 do not match', () => { + const asV1: Announcement = { ...mine, schemeId: SCHEME_ID }; + expect(run('fast', [asV1])).toEqual([]); + expect(run('balanced', [asV1])).toEqual([]); + }); + + it('rejects a wrong tag in fast/balanced; full still finds the payment', () => { + const wrongTag: Announcement = { + ...mine, + metadata: bytesToHex(new Uint8Array([(parseInt(mine.metadata, 16) + 1) % 256])), + }; + expect(run('fast', [wrongTag])).toEqual([]); + expect(run('balanced', [wrongTag])).toEqual([]); + expect(run('full', [wrongTag])).toEqual([mine.stealthAddress]); + }); + + it('balanced cross-checks the address; fast trusts the tag', () => { + const spoofed: Announcement = { ...mine, stealthAddress: noise.stealthAddress }; + expect(run('fast', [spoofed])).toHaveLength(1); + expect(run('balanced', [spoofed])).toEqual([]); + expect(run('full', [spoofed])).toEqual([]); + }); + + it('ignores unsupported schemes', () => { + const unknown: Announcement = { ...mine, schemeId: 3 }; + for (const strategy of STRATEGIES) expect(run(strategy, [unknown])).toEqual([]); + }); + + it('handles scheme 1 and scheme 2 announcements in one pass', () => { + const g = generateStealthAddress(recipient.spendingPubKey, recipient.viewingPubKey); + const v1: Announcement = { + schemeId: SCHEME_ID, + stealthAddress: g.stealthAddress, + caller: 'GCALLER', + ephemeralPubKey: bytesToHex(g.ephemeralPubKey), + metadata: bytesToHex(new Uint8Array([g.viewTag])), + }; + expect(run('balanced', [v1, noise, mine]).sort()).toEqual( + [v1.stealthAddress, mine.stealthAddress].sort(), + ); + }); +});