From dc804b5c2fd18f403e698bcf83368f0bfb023d88 Mon Sep 17 00:00:00 2001 From: Jerryvic911 Date: Sat, 17 Oct 2026 21:55:49 +0100 Subject: [PATCH 1/6] feat(sw): finish background scanning without spending authority (#212) --- src/hooks/useStellarNotifications.ts | 183 ++++-------------------- src/lib/stellar/announcementEvent.ts | 22 +++ src/sw/accountRecords.test.ts | 20 +++ src/sw/accountRecords.ts | 64 +++++++++ src/sw/announcementFetch.test.ts | 31 +++++ src/sw/announcementFetch.ts | 73 ++++++++++ src/sw/app-sw.ts | 135 ++++++++---------- src/sw/backgroundScan.test.ts | 157 +++++++++++++++++++++ src/sw/backgroundScan.ts | 101 ++++++++++++++ src/sw/idbScanKeyBackend.ts | 89 ++++++++++++ src/sw/noSpendingScalar.test.ts | 21 +++ src/sw/scanKeyStore.test.ts | 66 +++++++++ src/sw/scanKeyStore.ts | 185 +++++++++++++++++++++++++ src/sw/stellar-notification-sw.ts | 128 ++++++++--------- src/sw/testing/memoryScanKeyBackend.ts | 52 +++++++ src/workers/stellarScanMatch.test.ts | 82 +++++++++++ src/workers/stellarScanMatch.ts | 65 +++++++++ 17 files changed, 1171 insertions(+), 303 deletions(-) create mode 100644 src/lib/stellar/announcementEvent.ts create mode 100644 src/sw/accountRecords.test.ts create mode 100644 src/sw/accountRecords.ts create mode 100644 src/sw/announcementFetch.test.ts create mode 100644 src/sw/announcementFetch.ts create mode 100644 src/sw/backgroundScan.test.ts create mode 100644 src/sw/backgroundScan.ts create mode 100644 src/sw/idbScanKeyBackend.ts create mode 100644 src/sw/noSpendingScalar.test.ts create mode 100644 src/sw/scanKeyStore.test.ts create mode 100644 src/sw/scanKeyStore.ts create mode 100644 src/sw/testing/memoryScanKeyBackend.ts create mode 100644 src/workers/stellarScanMatch.test.ts create mode 100644 src/workers/stellarScanMatch.ts diff --git a/src/hooks/useStellarNotifications.ts b/src/hooks/useStellarNotifications.ts index c19936e..2dc8e12 100644 --- a/src/hooks/useStellarNotifications.ts +++ b/src/hooks/useStellarNotifications.ts @@ -4,19 +4,25 @@ * 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'; -const SW_PATH = '/sw/stellar-notification-sw.js'; 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 +56,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,9 +78,8 @@ export function useStellarNotifications(): UseStellarNotificationsReturn { async function registerSW() { try { - const registration = await navigator.serviceWorker.register(SW_PATH, { - type: 'module', - }); + // app-sw.ts is the registered worker and contains the scan/notification handlers. + const registration = await navigator.serviceWorker.ready; if (cancelled) return; @@ -124,49 +120,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; @@ -208,103 +161,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); } @@ -313,22 +197,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.ts b/src/lib/stellar/announcementEvent.ts new file mode 100644 index 0000000..1fec756 --- /dev/null +++ b/src/lib/stellar/announcementEvent.ts @@ -0,0 +1,22 @@ +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 an announcement. */ +export function parseAnnouncementEvent(event: Record): Announcement | null { + const topics = event.topic as string[]; + if (!topics || topics.length < 3) return 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 }; +} 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/announcementFetch.test.ts b/src/sw/announcementFetch.test.ts new file mode 100644 index 0000000..3122fce --- /dev/null +++ b/src/sw/announcementFetch.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { fetchAnnouncements } from './announcementFetch'; + +describe('fetchAnnouncements', () => { + it('follows the cursor to the last page and reports the latest ledger', async () => { + const bodies: { params: Record }[] = []; + const pages = [ + { + result: { + events: Array.from({ length: 1000 }, () => ({})), + cursor: 'c1', + latestLedger: 500, + }, + }, + { result: { events: [{}, {}], cursor: 'c2', latestLedger: 502 } }, + ]; + const fetchFn = (async (_url: string, init: RequestInit) => { + bodies.push(JSON.parse(String(init.body))); + return new Response(JSON.stringify(pages[bodies.length - 1])); + }) as unknown as typeof fetch; + + const result = await fetchAnnouncements('https://rpc.test', 'CCONTRACT', 42, fetchFn); + + expect(bodies).toHaveLength(2); + expect(bodies[0].params.startLedger).toBe(42); + expect(bodies[0].params.pagination.cursor).toBeUndefined(); + expect(bodies[1].params.pagination.cursor).toBe('c1'); + expect(bodies[1].params.startLedger).toBeUndefined(); + expect(result).toEqual({ announcements: [], latestLedger: 502 }); + }); +}); diff --git a/src/sw/announcementFetch.ts b/src/sw/announcementFetch.ts new file mode 100644 index 0000000..6a8ac6e --- /dev/null +++ b/src/sw/announcementFetch.ts @@ -0,0 +1,73 @@ +import type { Announcement } from '@wraith-protocol/sdk/chains/stellar'; +import { parseAnnouncementEvent } from '../lib/stellar/announcementEvent'; +import { retentionErrorFromRpcMessage } from '../lib/stellar/scannerCursor'; + +const PAGE_LIMIT = 1000; +const MAX_PAGES = 500; +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +async function latestLedgerFromRpc(rpcUrl: string, fetchFn: typeof fetch): Promise { + const response = await fetchFn(rpcUrl, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger' }), + }); + const sequence = Number((await response.json()).result?.sequence); + if (!Number.isSafeInteger(sequence) || sequence <= 0) { + throw new Error('Stellar RPC did not return a valid latest ledger'); + } + return sequence; +} + +/** Fetches every announcement from startLedger, following RPC pagination. */ +export async function fetchAnnouncements( + rpcUrl: string, + contractId: string, + startLedger: number, + fetchFn: typeof fetch = fetch, +): Promise<{ announcements: Announcement[]; latestLedger: number }> { + const announcements: Announcement[] = []; + let cursor: string | undefined; + let latestLedger = 0; + + for (let page = 0; page < MAX_PAGES; page++) { + const pagination: Record = { limit: PAGE_LIMIT }; + const params: Record = { + filters: [{ type: 'contract', contractIds: [contractId] }], + pagination, + }; + if (cursor) pagination.cursor = cursor; + else params.startLedger = startLedger; + + const response = await fetchFn(rpcUrl, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'getEvents', params }), + }); + const data = await response.json(); + if (data.error?.message) { + const message = String(data.error.message); + throw retentionErrorFromRpcMessage(startLedger, message) ?? new Error(message); + } + + const events: Record[] = data.result?.events ?? []; + for (const event of events) { + try { + const announcement = parseAnnouncementEvent(event); + if (announcement) announcements.push(announcement); + } catch { + // Skip malformed events without losing the scan cursor. + } + } + + const reported = Number(data.result?.latestLedger); + if (Number.isSafeInteger(reported) && reported > 0) latestLedger = reported; + + cursor = data.result?.cursor; + if (events.length < PAGE_LIMIT || !cursor) { + if (latestLedger === 0) latestLedger = await latestLedgerFromRpc(rpcUrl, fetchFn); + return { announcements, latestLedger }; + } + } + throw new Error('Too many announcement pages; scan stopped without advancing the cursor'); +} diff --git a/src/sw/app-sw.ts b/src/sw/app-sw.ts index 913db24..06617b5 100644 --- a/src/sw/app-sw.ts +++ b/src/sw/app-sw.ts @@ -7,16 +7,17 @@ import { NetworkFirst, CacheFirst } from 'workbox-strategies'; import { ExpirationPlugin } from 'workbox-expiration'; import { CacheableResponsePlugin } from 'workbox-cacheable-response'; import { CACHE_PREFIX, CACHE_VERSION_SUFFIX, cacheName, purgeObsoleteCaches } from '../lib/swCache'; -import { - retentionErrorFromRpcMessage, - retentionGapFromError, - type RetentionGap, -} from '../lib/stellar/scannerCursor'; +import { retentionGapFromError, type RetentionGap } from '../lib/stellar/scannerCursor'; import { deliverPushNotification, type NotificationPresentation, type ValidatedPushPayload, } from './notificationPayload'; +import { scanWithStoredKey, deliverMatch, type MatchNotifier } from './backgroundScan'; +import { fetchAnnouncements } from './announcementFetch'; +import { createIdbScanKeyBackend } from './idbScanKeyBackend'; +import { findSpendingSecretField, removeScanKey } from './scanKeyStore'; +import { scrubLegacySpendingMaterial, upsertAccountRecord } from './accountRecords'; import { claimNotificationId, createNotificationStore, @@ -107,9 +108,6 @@ const QUEUE_SYNC_TAG = 'wraith-offline-queue'; interface StoredViewingKey { publicKey: string; - encryptedViewingKey: string; - encryptedSpendingPubKey: string; - encryptedSpendingScalar: string; lastScannedLedger?: number; timestamp: number; relayUrl?: string; @@ -180,44 +178,44 @@ async function updateLastScannedLedger( // ── Stellar RPC helpers ──────────────────────────────────────────────────────── -async function fetchLatestLedger(): Promise { - const response = await fetch(STELLAR_RPC_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger' }), - }); - const data = await response.json(); - return data.result?.sequence || 0; -} +const matchNotifier: MatchNotifier = { + showNotification: (title, options) => self.registration.showNotification(title, options), + broadcast: async (message) => { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + for (const client of clients) client.postMessage(message); + }, +}; -async function fetchAnnouncementEvents( +async function scanStoredKey( + db: IDBDatabase, + storedKey: StoredViewingKey, startLedger: number, - contractId: string = ANNOUNCER_CONTRACT, -): Promise<{ events: unknown[]; latestLedger: number }> { - const response = await fetch(STELLAR_RPC_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'getEvents', - params: { - startLedger, - filters: [{ type: 'contract', contractIds: [contractId] }], - pagination: { limit: 1000 }, - }, - }), +): Promise { + const result = await scanWithStoredKey({ + backend: createIdbScanKeyBackend(), + publicKey: storedKey.publicKey, + loadAnnouncements: () => fetchAnnouncements(STELLAR_RPC_URL, ANNOUNCER_CONTRACT, startLedger), + notifyMatch: (match) => deliverMatch(matchNotifier, match), }); - const data = await response.json(); - if (data.error?.message) { - const retentionError = retentionErrorFromRpcMessage(startLedger, String(data.error.message)); - throw retentionError ?? new Error(String(data.error.message)); - } - const events = data.result?.events || []; - const latestLedger = await fetchLatestLedger(); - return { events, latestLedger }; + // Locked / not registered: keep the cursor so nothing is skipped after unlock. + if (result.status !== 'scanned') return; + await updateLastScannedLedger(db, storedKey.publicKey, result.nextLedger); } +async function scrubLegacyKeys(): Promise { + try { + const db = await openDB(); + try { + const scrubbed = await scrubLegacySpendingMaterial(db, STORE_NAME); + if (scrubbed > 0) + console.log(`[app-sw] Removed legacy key fields from ${scrubbed} record(s)`); + } finally { + db.close(); + } + } catch (error) { + console.error('[app-sw] Legacy key scrub failed:', error); + } +} async function notifyRetentionGap(publicKey: string, gap: RetentionGap): Promise { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); for (const client of clients) { @@ -225,19 +223,6 @@ async function notifyRetentionGap(publicKey: string, gap: RetentionGap): Promise } } -async function scanStoredKey( - db: IDBDatabase, - storedKey: StoredViewingKey, - startLedger: number, -): Promise { - const { events, latestLedger } = await fetchAnnouncementEvents(startLedger); - if (events.length > 0) { - console.log(`Found ${events.length} events for ${storedKey.publicKey}`); - // TODO: decrypt and scan with Wraith SDK when bundled in SW context - } - await updateLastScannedLedger(db, storedKey.publicKey, latestLedger + 1); -} - // ── Background sync handler ──────────────────────────────────────────────────── async function handleSync(): Promise { @@ -301,6 +286,7 @@ self.addEventListener('activate', (event) => { event.waitUntil( Promise.all([ self.clients.claim(), + scrubLegacyKeys(), // Drop every cache this app owns that is not part of the running version: // superseded versions, newer versions left behind by a roll-back, and // caches written by installs that predate explicit versioning. @@ -358,8 +344,17 @@ self.addEventListener('notificationclick', (event) => { // ── Message handler ──────────────────────────────────────────────────────────── self.addEventListener('message', (event) => { - const { type, publicKey, encryptedViewingKey, encryptedSpendingPubKey, encryptedSpendingScalar } = - event.data; + const { type, publicKey } = event.data; + + 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; + } if (type === 'SKIP_WAITING') { self.skipWaiting(); @@ -408,22 +403,15 @@ self.addEventListener('message', (event) => { event.waitUntil( (async () => { try { + if (typeof publicKey !== 'string' || publicKey.length === 0) { + throw new Error('Missing publicKey'); + } const db = await openDB(); - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const data: StoredViewingKey = { - publicKey, - encryptedViewingKey, - encryptedSpendingPubKey, - encryptedSpendingScalar, - timestamp: Date.now(), - }; - await new Promise((resolve, reject) => { - const request = store.put(data); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - db.close(); + try { + await upsertAccountRecord(db, STORE_NAME, publicKey); + } finally { + db.close(); + } (event.source as Client)?.postMessage({ type: 'VIEWING_KEY_REGISTERED' }); } catch (error) { console.error('[app-sw] Failed to register viewing key:', error); @@ -448,7 +436,7 @@ self.addEventListener('message', (event) => { request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); - + await removeScanKey(createIdbScanKeyBackend(), publicKey); // Unregister periodic sync if no keys remain const allKeys = await new Promise((resolve, reject) => { const tx = db.transaction([STORE_NAME], 'readonly'); @@ -503,9 +491,6 @@ self.addEventListener('message', (event) => { const entry: StoredViewingKey = existing || { publicKey: subscription.keys?.p256dh || 'default', - encryptedViewingKey: '', - encryptedSpendingPubKey: '', - encryptedSpendingScalar: '', timestamp: Date.now(), }; diff --git a/src/sw/backgroundScan.test.ts b/src/sw/backgroundScan.test.ts new file mode 100644 index 0000000..5c3b603 --- /dev/null +++ b/src/sw/backgroundScan.test.ts @@ -0,0 +1,157 @@ +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, scanWithStoredKey, type BackgroundMatch } from './backgroundScan'; +import { + lockAllScanKeys, + lockScanKey, + removeScanKey, + sealScanKey, + toScanKeyMaterial, +} from './scanKeyStore'; +import 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 noise = makeAnnouncement(stranger.spendingPubKey, stranger.viewingPubKey); + const notified: BackgroundMatch[] = []; + const loadAnnouncements = vi.fn(async () => ({ + announcements: [noise, mine], + latestLedger: 100, + })); + const scan = (backend: ScanKeyBackend, publicKey = ACCOUNT) => + scanWithStoredKey({ + backend, + publicKey, + loadAnnouncements, + notifyMatch: async (m) => { + notified.push(m); + }, + }); + const registered = async () => { + const backend = new MemoryScanKeyBackend(); + await sealScanKey(backend, ACCOUNT, toScanKeyMaterial(recipient)); + return backend; + }; + return { recipient, mine, notified, loadAnnouncements, scan, registered }; +} + +describe('background scanning', () => { + it('notifies only for matching announcements and leaks no secrets', async () => { + const { recipient, mine, notified, scan, registered } = setup(); + const backend = await registered(); + + const result = await scan(backend); + + expect(result).toMatchObject({ status: 'scanned', nextLedger: 101 }); + expect(notified).toEqual([{ publicKey: ACCOUNT, stealthAddress: mine.stealthAddress }]); + + const wire = JSON.stringify([result, notified, 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: does not decrypt, fetch or notify; unlocking resumes scanning', async () => { + const { recipient, notified, loadAnnouncements, scan, registered } = await (async () => { + const s = setup(); + return { ...s, registered: s.registered }; + })(); + const backend = await registered(); + + await lockScanKey(backend, ACCOUNT); + expect(await scan(backend)).toEqual({ status: 'locked' }); + expect(loadAnnouncements).not.toHaveBeenCalled(); + expect(notified).toHaveLength(0); + + await sealScanKey(backend, ACCOUNT, toScanKeyMaterial(recipient)); // unlock + expect((await scan(backend)).status).toBe('scanned'); + expect(notified).toHaveLength(1); + }); + + it('lock-all locks every account', async () => { + const { scan, registered } = setup(); + const backend = await registered(); + await lockAllScanKeys(backend); + expect((await scan(backend)).status).toBe('locked'); + }); + + it('lock during a scan suppresses the notification', async () => { + const { mine, notified, loadAnnouncements, scan, registered } = setup(); + const backend = await registered(); + loadAnnouncements.mockImplementationOnce(async () => { + await lockScanKey(backend, ACCOUNT); + return { announcements: [mine], latestLedger: 100 }; + }); + + expect(await scan(backend)).toEqual({ status: 'locked' }); + expect(notified).toHaveLength(0); + }); + + it('restart: a fresh worker resumes from persisted state alone', async () => { + const { notified, scan, registered } = setup(); + const backend = await registered(); + + const afterRestart = backend.restart(); + expect((await scan(afterRestart)).status).toBe('scanned'); + expect(notified).toHaveLength(1); + }); + + it('restart: a locked key stays locked', async () => { + const { scan, registered } = setup(); + const backend = await registered(); + await lockScanKey(backend, ACCOUNT); + + expect((await scan(backend.restart())).status).toBe('locked'); + }); + + it('key removal wipes everything and the worker goes idle', async () => { + const { notified, loadAnnouncements, scan, registered } = setup(); + const backend = await registered(); + + await removeScanKey(backend, ACCOUNT); + + expect(backend.records.size).toBe(0); + expect(backend.keys.size).toBe(0); + expect(await scan(backend.restart())).toEqual({ status: 'missing' }); + expect(loadAnnouncements).not.toHaveBeenCalled(); + expect(notified).toHaveLength(0); + }); + + it('a sealed blob cannot be reused for another account', async () => { + const { scan, 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 scan(backend, 'GOTHER')).status).toBe('invalid'); + }); +}); diff --git a/src/sw/backgroundScan.ts b/src/sw/backgroundScan.ts new file mode 100644 index 0000000..a22455b --- /dev/null +++ b/src/sw/backgroundScan.ts @@ -0,0 +1,101 @@ +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 BackgroundScanDeps { + backend: ScanKeyBackend; + publicKey: string; + loadAnnouncements: () => Promise<{ announcements: Announcement[]; latestLedger: number }>; + notifyMatch: (match: BackgroundMatch) => Promise; + strategy?: ScanStrategy; +} + +export type BackgroundScanResult = + | { status: 'scanned'; matches: BackgroundMatch[]; nextLedger: number } + | { status: 'locked' | 'missing' | 'invalid' }; + +/** + * One background scan pass. Holds no state between calls: the key is re-opened + * from storage every time, so lock, restart and removal all take effect at once. + * Callers must only advance their cursor when status is 'scanned'. + */ +export async function scanWithStoredKey(deps: BackgroundScanDeps): Promise { + const opened = await openScanKey(deps.backend, deps.publicKey); + if (opened.status !== 'ok') return { status: opened.status }; // no network when locked + + const { material } = opened; + try { + const { announcements, latestLedger } = await deps.loadAnnouncements(); + + // The key may have been locked or removed while we were on the network. + if (!(await isScanKeyUnlocked(deps.backend, deps.publicKey))) return { status: 'locked' }; + + const found = findMatches( + deps.strategy ?? DEFAULT_SCAN_STRATEGY, + announcements, + material.viewingKey, + material.spendingPubKey, + ); + const matches = found.map((m) => ({ + publicKey: deps.publicKey, + stealthAddress: m.stealthAddress, + })); + for (const match of matches) await deps.notifyMatch(match); + return { status: 'scanned', matches, nextLedger: latestLedger + 1 }; + } finally { + wipeScanKeyMaterial(material); + } +} + +// ─── 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..f3d53a7 --- /dev/null +++ b/src/sw/noSpendingScalar.test.ts @@ -0,0 +1,21 @@ +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/sw/stellar-notification-sw.ts', + 'src/hooks/useStellarNotifications.ts', + 'src/sw/backgroundScan.ts', + 'src/sw/scanKeyStore.ts', + 'src/sw/idbScanKeyBackend.ts', + 'src/sw/announcementFetch.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/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/stellar-notification-sw.ts b/src/sw/stellar-notification-sw.ts index d6d65c4..2b42469 100644 --- a/src/sw/stellar-notification-sw.ts +++ b/src/sw/stellar-notification-sw.ts @@ -27,11 +27,7 @@ */ /// -import { - retentionErrorFromRpcMessage, - retentionGapFromError, - type RetentionGap, -} from '../lib/stellar/scannerCursor'; +import { retentionGapFromError, type RetentionGap } from '../lib/stellar/scannerCursor'; import { deliverPushNotification, type NotificationPresentation, @@ -43,6 +39,11 @@ import { pruneNotificationIds, releaseNotificationId, } from './notificationStore'; +import { scanWithStoredKey, deliverMatch, type MatchNotifier } from './backgroundScan'; +import { fetchAnnouncements } from './announcementFetch'; +import { createIdbScanKeyBackend } from './idbScanKeyBackend'; +import { findSpendingSecretField, removeScanKey } from './scanKeyStore'; +import { scrubLegacySpendingMaterial, upsertAccountRecord } from './accountRecords'; export {}; declare const self: ServiceWorkerGlobalScope; @@ -62,9 +63,6 @@ const SYNC_INTERVAL_MINUTES = 15; interface StoredViewingKey { publicKey: string; - encryptedViewingKey: string; - encryptedSpendingPubKey: string; - encryptedSpendingScalar: string; lastScannedLedger?: number; timestamp: number; relayUrl?: string; @@ -135,44 +133,6 @@ async function updateLastScannedLedger( // ─── Stellar RPC helpers ────────────────────────────────────────────────────── -async function fetchLatestLedger(): Promise { - const response = await fetch(STELLAR_RPC_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger' }), - }); - const data = await response.json(); - return data.result?.sequence || 0; -} - -async function fetchAnnouncementEvents( - startLedger: number, - contractId: string = ANNOUNCER_CONTRACT, -): Promise<{ events: unknown[]; latestLedger: number }> { - const response = await fetch(STELLAR_RPC_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'getEvents', - params: { - startLedger, - filters: [{ type: 'contract', contractIds: [contractId] }], - pagination: { limit: 1000 }, - }, - }), - }); - const data = await response.json(); - if (data.error?.message) { - const retentionError = retentionErrorFromRpcMessage(startLedger, String(data.error.message)); - throw retentionError ?? new Error(String(data.error.message)); - } - const events = data.result?.events || []; - const latestLedger = await fetchLatestLedger(); - return { events, latestLedger }; -} - async function notifyRetentionGap(publicKey: string, gap: RetentionGap): Promise { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); for (const client of clients) { @@ -180,17 +140,43 @@ async function notifyRetentionGap(publicKey: string, gap: RetentionGap): Promise } } +const matchNotifier: MatchNotifier = { + showNotification: (title, options) => self.registration.showNotification(title, options), + broadcast: async (message) => { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + for (const client of clients) client.postMessage(message); + }, +}; + async function scanStoredKey( db: IDBDatabase, storedKey: StoredViewingKey, startLedger: number, ): Promise { - const { events, latestLedger } = await fetchAnnouncementEvents(startLedger); - if (events.length > 0) { - console.log(`[wraith-sw] Found ${events.length} events for ${storedKey.publicKey}`); - // TODO: decrypt viewing key and scan with the SDK when bundled in the worker. + const result = await scanWithStoredKey({ + backend: createIdbScanKeyBackend(), + publicKey: storedKey.publicKey, + loadAnnouncements: () => fetchAnnouncements(STELLAR_RPC_URL, ANNOUNCER_CONTRACT, startLedger), + notifyMatch: (match) => deliverMatch(matchNotifier, match), + }); + // Locked / not registered: keep the cursor so nothing is skipped after unlock. + if (result.status !== 'scanned') return; + await updateLastScannedLedger(db, storedKey.publicKey, result.nextLedger); +} + +async function scrubLegacyKeys(): Promise { + try { + const db = await openDB(); + try { + const scrubbed = await scrubLegacySpendingMaterial(db, STORE_NAME); + if (scrubbed > 0) + console.log(`[wraith-sw] Removed legacy key fields from ${scrubbed} record(s)`); + } finally { + db.close(); + } + } catch (error) { + console.error('[wraith-sw] Legacy key scrub failed:', error); } - await updateLastScannedLedger(db, storedKey.publicKey, latestLedger + 1); } // ─── push payload helpers ───────────────────────────────────────────────────── @@ -313,8 +299,16 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { // ─── message handler ────────────────────────────────────────────────────────── self.addEventListener('message', (event: ExtendableMessageEvent) => { - const { type, publicKey, encryptedViewingKey, encryptedSpendingPubKey, encryptedSpendingScalar } = - event.data ?? {}; + const { type, publicKey } = event.data ?? {}; + const forbidden = findSpendingSecretField(event.data); + if (forbidden) { + console.error(`[wraith-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; + } if (type === 'RECOVER_SCAN_CURSOR') { event.waitUntil( @@ -358,22 +352,15 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { event.waitUntil( (async () => { try { + if (typeof publicKey !== 'string' || publicKey.length === 0) { + throw new Error('Missing publicKey'); + } const db = await openDB(); - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const entry: StoredViewingKey = { - publicKey, - encryptedViewingKey, - encryptedSpendingPubKey, - encryptedSpendingScalar, - timestamp: Date.now(), - }; - await new Promise((resolve, reject) => { - const request = store.put(entry); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - db.close(); + try { + await upsertAccountRecord(db, STORE_NAME, publicKey); + } finally { + db.close(); + } (event.source as Client)?.postMessage({ type: 'VIEWING_KEY_REGISTERED' }); } catch (error) { console.error('[wraith-sw] Failed to register viewing key:', error); @@ -399,6 +386,7 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { request.onsuccess = () => resolve(); }); db.close(); + await removeScanKey(createIdbScanKeyBackend(), publicKey); // Unregister periodic sync if no keys remain const db2 = await openDB(); @@ -455,9 +443,6 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const entry: StoredViewingKey = existing || { publicKey: subscription.keys?.p256dh || 'default', - encryptedViewingKey: '', - encryptedSpendingPubKey: '', - encryptedSpendingScalar: '', timestamp: Date.now(), }; @@ -541,6 +526,7 @@ self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil( Promise.all([ self.clients.claim(), + scrubLegacyKeys(), (async () => { if ('periodicSync' in self.registration) { try { 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..e0f04ee --- /dev/null +++ b/src/workers/stellarScanMatch.ts @@ -0,0 +1,65 @@ +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'; + +/** A detected payment. Deliberately has no private scalar: claiming happens on the main thread. */ +export interface AnnouncementMatch { + announcement: Announcement; + stealthAddress: string; +} + +/** + * Same strategies as scanWithStrategy, but needs only the viewing key and the + * spending PUBLIC key, so it can run where spend authority must never exist. + */ +export function findMatches( + strategy: ScanStrategy, + announcements: Announcement[], + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, +): AnnouncementMatch[] { + const matches: AnnouncementMatch[] = []; + + for (const announcement of announcements) { + try { + if (announcement.schemeId !== SCHEME_ID) continue; + const ephPubKey = hexToBytes(announcement.ephemeralPubKey); + if (ephPubKey.length !== 32) continue; + + if (strategy === 'full') { + const hashScalar = hashToScalar(computeSharedSecret(viewingKey, ephPubKey)); + const stealthPubKey = deriveStealthPubKey(spendingPubKey, hashScalar); + if (pubKeyToStellarAddress(stealthPubKey) === announcement.stealthAddress) { + matches.push({ announcement, stealthAddress: announcement.stealthAddress }); + } + continue; + } + + const metadata = hexToBytes(announcement.metadata); + if (metadata.length === 0) continue; + const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, metadata[0]); + if (!result.isMatch || result.stealthPubKeyBytes === null) continue; + + // balanced (and any unknown value) also cross-checks the derived address + if ( + strategy !== 'fast' && + pubKeyToStellarAddress(result.stealthPubKeyBytes) !== announcement.stealthAddress + ) { + continue; + } + matches.push({ announcement, stealthAddress: announcement.stealthAddress }); + } catch { + // A malformed announcement must not abort the whole background scan. + } + } + + return matches; +} From 7f62d6cb12d6cc489ab697248a20f2ec3a321fd5 Mon Sep 17 00:00:00 2001 From: Jerryvic911 Date: Sun, 18 Oct 2026 13:33:43 +0100 Subject: [PATCH 2/6] fix(sw): follow RPC cursors to the end and validate getEvents responses (#212) --- src/sw/announcementFetch.test.ts | 136 +++++++++++++++++++++++++------ src/sw/announcementFetch.ts | 80 +++++++++++++----- 2 files changed, 170 insertions(+), 46 deletions(-) diff --git a/src/sw/announcementFetch.test.ts b/src/sw/announcementFetch.test.ts index 3122fce..1c2471e 100644 --- a/src/sw/announcementFetch.test.ts +++ b/src/sw/announcementFetch.test.ts @@ -1,31 +1,117 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +// Events with a string `id` decode to an announcement; anything else decodes to null. +vi.mock('../lib/stellar/announcementEvent', () => ({ + parseAnnouncementEvent: (event: Record) => + typeof event.id === 'string' + ? { schemeId: 1, stealthAddress: event.id, caller: 'C', ephemeralPubKey: '', metadata: '' } + : null, +})); + import { fetchAnnouncements } from './announcementFetch'; -describe('fetchAnnouncements', () => { - it('follows the cursor to the last page and reports the latest ledger', async () => { - const bodies: { params: Record }[] = []; - const pages = [ - { - result: { - events: Array.from({ length: 1000 }, () => ({})), - cursor: 'c1', - latestLedger: 500, - }, - }, - { result: { events: [{}, {}], cursor: 'c2', latestLedger: 502 } }, - ]; - const fetchFn = (async (_url: string, init: RequestInit) => { - bodies.push(JSON.parse(String(init.body))); - return new Response(JSON.stringify(pages[bodies.length - 1])); - }) as unknown as typeof fetch; - - const result = await fetchAnnouncements('https://rpc.test', 'CCONTRACT', 42, fetchFn); +type Body = { + method: string; + params?: { startLedger?: number; pagination?: { cursor?: string } }; +}; +type Reply = { status?: number; json: unknown }; + +function rpc(replies: Reply[]) { + const bodies: Body[] = []; + const fetchFn = (async (_url: string, init: RequestInit) => { + bodies.push(JSON.parse(String(init.body)) as Body); + const reply = replies[bodies.length - 1]; + if (!reply) throw new Error('Unexpected extra RPC call'); + return new Response(JSON.stringify(reply.json), { status: reply.status ?? 200 }); + }) as unknown as typeof fetch; + const run = () => fetchAnnouncements('https://rpc.test', 'CCONTRACT', 42, fetchFn); + return { bodies, run }; +} + +const page = (ids: string[], cursor?: string, latestLedger = 500): Reply => ({ + json: { result: { events: ids.map((id) => ({ id })), cursor, latestLedger } }, +}); +const addresses = (r: { announcements: { stealthAddress: string }[] }) => + r.announcements.map((a) => a.stealthAddress); + +describe('fetchAnnouncements pagination', () => { + it('follows the cursor across full pages and reads the last page', async () => { + const full = Array.from({ length: 1000 }, (_, i) => `a${i}`); + const { bodies, run } = rpc([page(full, 'c1'), page(['b'], 'c2', 502), page([], 'c2', 503)]); + + const result = await run(); + + expect(result.announcements).toHaveLength(1001); + expect(result.latestLedger).toBe(503); + expect(bodies).toHaveLength(3); + expect(bodies[0].params?.startLedger).toBe(42); + expect(bodies[1].params?.pagination?.cursor).toBe('c1'); + expect(bodies[1].params?.startLedger).toBeUndefined(); + }); + + it('keeps reading after a SHORT page when the RPC returns a cursor', async () => { + const { bodies, run } = rpc([page(['a', 'b'], 'c1'), page(['c'], 'c2'), page([], 'c2', 600)]); + + const result = await run(); + + expect(addresses(result)).toEqual(['a', 'b', 'c']); + expect(result.latestLedger).toBe(600); + expect(bodies).toHaveLength(3); + expect(bodies[1].params?.pagination?.cursor).toBe('c1'); + }); + + it('stops when the cursor is absent', async () => { + const { bodies, run } = rpc([page(['a'])]); + expect(addresses(await run())).toEqual(['a']); + expect(bodies).toHaveLength(1); + }); + it('stops when the cursor stops advancing', async () => { + const { bodies, run } = rpc([page(['a'], 'c1'), page(['b'], 'c1')]); + expect(addresses(await run())).toEqual(['a', 'b']); expect(bodies).toHaveLength(2); - expect(bodies[0].params.startLedger).toBe(42); - expect(bodies[0].params.pagination.cursor).toBeUndefined(); - expect(bodies[1].params.pagination.cursor).toBe('c1'); - expect(bodies[1].params.startLedger).toBeUndefined(); - expect(result).toEqual({ announcements: [], latestLedger: 502 }); + }); + + it('skips an undecodable event but keeps the rest of the page', async () => { + const { run } = rpc([ + { json: { result: { events: [{ id: 'a' }, 'junk', null], latestLedger: 9 } } }, + ]); + expect(addresses(await run())).toEqual(['a']); + }); +}); + +describe('fetchAnnouncements response validation', () => { + it.each([ + ['no result', { json: {} }], + ['null result', { json: { result: null } }], + ['no events field', { json: { result: { latestLedger: 5 } } }], + ['non-array events', { json: { result: { events: 'nope', latestLedger: 5 } } }], + ['array envelope', { json: [] }], + ['JSON-RPC error', { json: { error: { message: 'boom' } } }], + ['HTTP 500', { status: 500, json: {} }], + ] as [string, Reply][])('rejects %s instead of reporting an empty scan', async (_name, reply) => { + await expect(rpc([reply]).run()).rejects.toThrow(); + }); + + it('rejects a malformed page in the middle of pagination', async () => { + const { run } = rpc([page(['a'], 'c1'), { json: { result: {} } }]); + await expect(run()).rejects.toThrow(); + }); + + it('asks getLatestLedger when the page has no valid latestLedger', async () => { + const { bodies, run } = rpc([ + { json: { result: { events: [{ id: 'a' }] } } }, + { json: { result: { sequence: 777 } } }, + ]); + expect((await run()).latestLedger).toBe(777); + expect(bodies[1].method).toBe('getLatestLedger'); + }); + + it('rejects when no valid latest ledger can be obtained', async () => { + const { run } = rpc([ + { json: { result: { events: [] } } }, + { json: { result: { sequence: 0 } } }, + ]); + await expect(run()).rejects.toThrow(); }); }); diff --git a/src/sw/announcementFetch.ts b/src/sw/announcementFetch.ts index 6a8ac6e..4a777fa 100644 --- a/src/sw/announcementFetch.ts +++ b/src/sw/announcementFetch.ts @@ -6,20 +6,56 @@ const PAGE_LIMIT = 1000; const MAX_PAGES = 500; const JSON_HEADERS = { 'Content-Type': 'application/json' }; -async function latestLedgerFromRpc(rpcUrl: string, fetchFn: typeof fetch): Promise { +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validLedger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +async function callRpc( + fetchFn: typeof fetch, + rpcUrl: string, + body: object, +): Promise> { const response = await fetchFn(rpcUrl, { method: 'POST', headers: JSON_HEADERS, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger' }), + body: JSON.stringify(body), }); - const sequence = Number((await response.json()).result?.sequence); - if (!Number.isSafeInteger(sequence) || sequence <= 0) { - throw new Error('Stellar RPC did not return a valid latest ledger'); + if (!response.ok) throw new Error(`Stellar RPC responded with HTTP ${response.status}`); + const data: unknown = await response.json(); + if (!isRecord(data)) throw new Error('Stellar RPC returned a malformed response'); + return data; +} + +/** Returns the JSON-RPC result, or throws. Nothing malformed may reach the caller's cursor. */ +function resultOf(data: Record, startLedger: number): Record { + if (data.error !== undefined && data.error !== null) { + const message = + isRecord(data.error) && typeof data.error.message === 'string' && data.error.message + ? data.error.message + : 'Stellar RPC returned an error'; + throw retentionErrorFromRpcMessage(startLedger, message) ?? new Error(message); } + if (!isRecord(data.result)) throw new Error('Stellar RPC response is missing a result'); + return data.result; +} + +async function latestLedgerFromRpc(rpcUrl: string, fetchFn: typeof fetch): Promise { + const data = await callRpc(fetchFn, rpcUrl, { jsonrpc: '2.0', id: 1, method: 'getLatestLedger' }); + const sequence = isRecord(data.result) ? validLedger(data.result.sequence) : undefined; + if (sequence === undefined) throw new Error('Stellar RPC did not return a valid latest ledger'); return sequence; } -/** Fetches every announcement from startLedger, following RPC pagination. */ +/** + * Fetches every announcement from startLedger. Pages are consumed until the RPC + * stops returning a new cursor; a short page is NOT the end. Any malformed + * response throws, so callers never advance their scan cursor past data they + * did not actually read. + */ export async function fetchAnnouncements( rpcUrl: string, contractId: string, @@ -39,35 +75,37 @@ export async function fetchAnnouncements( if (cursor) pagination.cursor = cursor; else params.startLedger = startLedger; - const response = await fetchFn(rpcUrl, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'getEvents', params }), + const data = await callRpc(fetchFn, rpcUrl, { + jsonrpc: '2.0', + id: 2, + method: 'getEvents', + params, }); - const data = await response.json(); - if (data.error?.message) { - const message = String(data.error.message); - throw retentionErrorFromRpcMessage(startLedger, message) ?? new Error(message); + const result = resultOf(data, startLedger); + if (!Array.isArray(result.events)) { + throw new Error('Stellar RPC result is missing an events array'); } - const events: Record[] = data.result?.events ?? []; - for (const event of events) { + for (const event of result.events) { + if (!isRecord(event)) continue; try { const announcement = parseAnnouncementEvent(event); if (announcement) announcements.push(announcement); } catch { - // Skip malformed events without losing the scan cursor. + // An individual undecodable event is skipped; the envelope itself was valid. } } - const reported = Number(data.result?.latestLedger); - if (Number.isSafeInteger(reported) && reported > 0) latestLedger = reported; + const reported = validLedger(result.latestLedger); + if (reported !== undefined) latestLedger = reported; - cursor = data.result?.cursor; - if (events.length < PAGE_LIMIT || !cursor) { + const next = + typeof result.cursor === 'string' && result.cursor.length > 0 ? result.cursor : undefined; + if (!next || next === cursor) { if (latestLedger === 0) latestLedger = await latestLedgerFromRpc(rpcUrl, fetchFn); return { announcements, latestLedger }; } + cursor = next; } throw new Error('Too many announcement pages; scan stopped without advancing the cursor'); } From 3a8f36d2d116163275f63d92afb7dd0fbd00ce6e Mon Sep 17 00:00:00 2001 From: Jerryvic911 Date: Sun, 18 Oct 2026 13:43:38 +0100 Subject: [PATCH 3/6] feat(sw): lock background scan keys on wallet disconnect and app lock (#212) --- src/components/StellarReceive.tsx | 20 ++- .../StealthKeysContext.lifecycle.test.tsx | 115 ++++++++++++++++++ src/context/StealthKeysContext.tsx | 3 + src/sw/scanKeyLifecycle.ts | 16 +++ 4 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 src/context/StealthKeysContext.lifecycle.test.tsx create mode 100644 src/sw/scanKeyLifecycle.ts diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index 63b9d4e..4fd2991 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -1314,6 +1314,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(); @@ -1321,12 +1334,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..85f50a9 --- /dev/null +++ b/src/context/StealthKeysContext.lifecycle.test.tsx @@ -0,0 +1,115 @@ +// @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 { scanWithStoredKey } 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 loadAnnouncements = vi.fn(async () => ({ announcements: [], latestLedger: 1 })); + const result = await scanWithStoredKey({ + backend, + publicKey: ACCOUNT, + loadAnnouncements, + notifyMatch: vi.fn(async () => {}), + }); + expect(result).toEqual({ status: 'locked' }); + expect(loadAnnouncements).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/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); + } +} From 1978aa64899c1f1ed4d3941cf4d6122c2320c871 Mon Sep 17 00:00:00 2001 From: Jerryvic911 Date: Sun, 18 Oct 2026 14:21:56 +0100 Subject: [PATCH 4/6] fix(sw): match scheme-2 announcements with the v2 view tag and event layout (#212) --- src/lib/stellar/announcementEvent.test.ts | 77 +++++++++++++++ src/lib/stellar/announcementEvent.ts | 37 +++++++- src/workers/stellarScanMatch.ts | 81 +++++++++++++--- src/workers/stellarScanMatch.v2.test.ts | 108 ++++++++++++++++++++++ 4 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 src/lib/stellar/announcementEvent.test.ts create mode 100644 src/workers/stellarScanMatch.v2.test.ts diff --git a/src/lib/stellar/announcementEvent.test.ts b/src/lib/stellar/announcementEvent.test.ts new file mode 100644 index 0000000..0dbd4f5 --- /dev/null +++ b/src/lib/stellar/announcementEvent.test.ts @@ -0,0 +1,77 @@ +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 } 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(); + }); +}); diff --git a/src/lib/stellar/announcementEvent.ts b/src/lib/stellar/announcementEvent.ts index 1fec756..487171e 100644 --- a/src/lib/stellar/announcementEvent.ts +++ b/src/lib/stellar/announcementEvent.ts @@ -2,11 +2,21 @@ 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 an announcement. */ +/** + * 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 as string[]; - if (!topics || topics.length < 3) return 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(); @@ -20,3 +30,24 @@ export function parseAnnouncementEvent(event: Record): Announce 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 }; +} diff --git a/src/workers/stellarScanMatch.ts b/src/workers/stellarScanMatch.ts index e0f04ee..8d824c3 100644 --- a/src/workers/stellarScanMatch.ts +++ b/src/workers/stellarScanMatch.ts @@ -1,3 +1,5 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; import { checkStealthAddress, computeSharedSecret, @@ -10,15 +12,49 @@ import { 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, but needs only the viewing key and the - * spending PUBLIC key, so it can run where spend authority must never exist. + * 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, @@ -27,35 +63,56 @@ export function findMatches( 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 { - if (announcement.schemeId !== SCHEME_ID) continue; + 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 hashScalar = hashToScalar(computeSharedSecret(viewingKey, ephPubKey)); - const stealthPubKey = deriveStealthPubKey(spendingPubKey, hashScalar); - if (pubKeyToStellarAddress(stealthPubKey) === announcement.stealthAddress) { - matches.push({ announcement, stealthAddress: announcement.stealthAddress }); - } + const address = deriveStealthAddress(viewingKey, spendingPubKey, ephPubKey); + if (address === announcement.stealthAddress) found(announcement); continue; } const metadata = hexToBytes(announcement.metadata); if (metadata.length === 0) continue; - const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, metadata[0]); - if (!result.isMatch || result.stealthPubKeyBytes === null) continue; + const viewTag = metadata[0]; - // balanced (and any unknown value) also cross-checks the derived address + 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; } - matches.push({ announcement, stealthAddress: announcement.stealthAddress }); + found(announcement); } catch { // A malformed announcement must not abort the whole background scan. } 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(), + ); + }); +}); From 3c01c7b389b1d4d42ac31a23f668d4665d57ef08 Mon Sep 17 00:00:00 2001 From: Jerryvic911 Date: Sun, 18 Oct 2026 19:32:52 +0100 Subject: [PATCH 5/6] fix(sw): fail on stalled RPC cursors; only an empty page at the latest ledger ends a scan (#212) --- src/sw/announcementFetch.test.ts | 50 +++++++++++++++++++++++++++----- src/sw/announcementFetch.ts | 32 ++++++++++++++++++-- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/sw/announcementFetch.test.ts b/src/sw/announcementFetch.test.ts index 1c2471e..fd4aa21 100644 --- a/src/sw/announcementFetch.test.ts +++ b/src/sw/announcementFetch.test.ts @@ -28,16 +28,31 @@ function rpc(replies: Reply[]) { return { bodies, run }; } +// 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`; + const page = (ids: string[], cursor?: string, latestLedger = 500): Reply => ({ json: { result: { events: ids.map((id) => ({ id })), cursor, latestLedger } }, }); const addresses = (r: { announcements: { stealthAddress: string }[] }) => r.announcements.map((a) => a.stealthAddress); +describe('tip cursor shape', () => { + it('matches the real testnet cursor', () => { + expect(tip(5256415)).toBe('0022576134814171135-4294967295'); + }); +}); + describe('fetchAnnouncements pagination', () => { - it('follows the cursor across full pages and reads the last page', async () => { + it('follows the cursor across full pages and finishes at the tip', async () => { const full = Array.from({ length: 1000 }, (_, i) => `a${i}`); - const { bodies, run } = rpc([page(full, 'c1'), page(['b'], 'c2', 502), page([], 'c2', 503)]); + const { bodies, run } = rpc([ + page(full, 'c1', 502), + page(['b'], tip(503), 503), + page([], tip(503), 503), + ]); const result = await run(); @@ -50,7 +65,11 @@ describe('fetchAnnouncements pagination', () => { }); it('keeps reading after a SHORT page when the RPC returns a cursor', async () => { - const { bodies, run } = rpc([page(['a', 'b'], 'c1'), page(['c'], 'c2'), page([], 'c2', 600)]); + const { bodies, run } = rpc([ + page(['a', 'b'], 'c1'), + page(['c'], tip(600), 600), + page([], tip(600), 600), + ]); const result = await run(); @@ -60,16 +79,33 @@ describe('fetchAnnouncements pagination', () => { expect(bodies[1].params?.pagination?.cursor).toBe('c1'); }); + it('finishes when an empty page echoes a cursor that has reached the latest ledger', async () => { + // What the real RPC does when nothing new has been written. + const { bodies, run } = rpc([page([], tip(700), 700), page([], tip(700), 700)]); + + const result = await run(); + + expect(result).toEqual({ announcements: [], latestLedger: 700 }); + expect(bodies).toHaveLength(2); + }); + it('stops when the cursor is absent', async () => { const { bodies, run } = rpc([page(['a'])]); expect(addresses(await run())).toEqual(['a']); expect(bodies).toHaveLength(1); }); - it('stops when the cursor stops advancing', async () => { - const { bodies, run } = rpc([page(['a'], 'c1'), page(['b'], 'c1')]); - expect(addresses(await run())).toEqual(['a', 'b']); - expect(bodies).toHaveLength(2); + it('rejects a repeated cursor on a page that returned events', async () => { + await expect(rpc([page(['a'], 'c1'), page(['b'], 'c1')]).run()).rejects.toThrow(/repeated/); + }); + + it('rejects a repeated cursor it cannot prove is the tip', async () => { + await expect(rpc([page(['a'], 'c1'), page([], 'c1')]).run()).rejects.toThrow(/repeated/); + }); + + it('rejects an empty repeated cursor that is still behind the latest ledger', async () => { + const { run } = rpc([page([], tip(100), 500), page([], tip(100), 500)]); + await expect(run()).rejects.toThrow(/repeated/); }); it('skips an undecodable event but keeps the rest of the page', async () => { diff --git a/src/sw/announcementFetch.ts b/src/sw/announcementFetch.ts index 4a777fa..ef6bbc0 100644 --- a/src/sw/announcementFetch.ts +++ b/src/sw/announcementFetch.ts @@ -14,6 +14,17 @@ function validLedger(value: unknown): number | undefined { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined; } +/** + * Ledger sequence encoded in a Soroban event cursor ("<19-digit TOID>-"). + * The TOID's high 32 bits are the ledger. Undefined if the cursor has another 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; +} + async function callRpc( fetchFn: typeof fetch, rpcUrl: string, @@ -85,8 +96,9 @@ export async function fetchAnnouncements( if (!Array.isArray(result.events)) { throw new Error('Stellar RPC result is missing an events array'); } + const events: unknown[] = result.events; - for (const event of result.events) { + for (const event of events) { if (!isRecord(event)) continue; try { const announcement = parseAnnouncementEvent(event); @@ -101,9 +113,25 @@ export async function fetchAnnouncements( const next = typeof result.cursor === 'string' && result.cursor.length > 0 ? result.cursor : undefined; - if (!next || next === cursor) { + + const finish = async () => { if (latestLedger === 0) latestLedger = await latestLedgerFromRpc(rpcUrl, fetchFn); return { announcements, latestLedger }; + }; + + // No continuation cursor: the RPC says there is nothing more. + if (!next) return finish(); + + if (next === cursor) { + // Sending a cursor and getting the same one back is only legitimate at the tip: + // an empty page whose cursor has already reached the latest ledger. Anything + // else is a stalled or malformed response; fail so the caller keeps its cursor. + if (latestLedger === 0) latestLedger = await latestLedgerFromRpc(rpcUrl, fetchFn); + const reached = cursorLedger(next); + if (events.length === 0 && reached !== undefined && reached >= latestLedger) return finish(); + throw new Error( + 'Stellar RPC repeated its continuation cursor before reaching the latest ledger; scan stopped without advancing the cursor', + ); } cursor = next; } From f72e28f2c36d325ff597d0370db1e09a2010752c Mon Sep 17 00:00:00 2001 From: Jerryvic911 Date: Mon, 19 Oct 2026 12:12:11 +0100 Subject: [PATCH 6/6] fix: resolve app-sw merge using validated message handler --- src/sw/app-sw.ts | 171 +++++------------------------------------------ 1 file changed, 18 insertions(+), 153 deletions(-) diff --git a/src/sw/app-sw.ts b/src/sw/app-sw.ts index 6f99a36..0c6f652 100644 --- a/src/sw/app-sw.ts +++ b/src/sw/app-sw.ts @@ -9,11 +9,6 @@ import { CacheableResponsePlugin } from 'workbox-cacheable-response'; import { CACHE_PREFIX, CACHE_VERSION_SUFFIX, cacheName, purgeObsoleteCaches } from '../lib/swCache'; import { fetchEventPages } from '../lib/stellar/rpcEvents'; import { retentionGapFromError, type RetentionGap } from '../lib/stellar/scannerCursor'; -import { decodeAnnouncementEvents } from '../lib/stellar/announcementEvent'; -import { scanPages, deliverMatch, type MatchNotifier } from './backgroundScan'; -import { createIdbScanKeyBackend } from './idbScanKeyBackend'; -import { findSpendingSecretField, removeScanKey } from './scanKeyStore'; -import { scrubLegacySpendingMaterial, upsertAccountRecord } from './accountRecords'; import { deliverPushNotification, type NotificationPresentation, @@ -117,6 +112,9 @@ const QUEUE_SYNC_TAG = 'wraith-offline-queue'; interface StoredViewingKey { publicKey: string; + encryptedViewingKey: string; + encryptedSpendingPubKey: string; + encryptedSpendingScalar: string; lastScannedLedger?: number; timestamp: number; relayUrl?: string; @@ -207,57 +205,25 @@ async function notifyRetentionGap(publicKey: string, gap: RetentionGap): Promise } } -const matchNotifier: MatchNotifier = { - showNotification: (title, options) => self.registration.showNotification(title, options), - broadcast: async (message) => { - const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - for (const client of clients) client.postMessage(message); - }, -}; - -/** - * Scans from the stored cursor through the shared lossless pager. The sealed scan key is - * opened first, so a locked or unregistered account costs no network. Each page is matched - * and notified BEFORE its cursor is persisted, and a lock between pages stops the scan - * without committing the unread page. - */ async function scanStoredKey( db: IDBDatabase, storedKey: StoredViewingKey, startLedger: number | undefined, ): Promise { - await scanPages({ - backend: createIdbScanKeyBackend(), - publicKey: storedKey.publicKey, - notifyMatch: (match) => deliverMatch(matchNotifier, match), - readPages: (onPage) => - fetchEventPages({ - rpcUrl: STELLAR_RPC_URL, - contractId: ANNOUNCER_CONTRACT, - // undefined lets the module clamp a first scan to the retention window. - startLedger, - onPage: (page) => - onPage({ - announcements: decodeAnnouncementEvents(page.events), - committedLedger: page.committedLedger, - }), - }), - commit: (nextLedger) => updateLastScannedLedger(db, storedKey.publicKey, nextLedger), + const { events } = await fetchEventPages({ + rpcUrl: STELLAR_RPC_URL, + contractId: ANNOUNCER_CONTRACT, + // `undefined` lets the module clamp a first scan to the retention window + // instead of asking for a ledger the RPC has already dropped. + startLedger, + onPage: async (page) => { + await updateLastScannedLedger(db, storedKey.publicKey, page.committedLedger + 1); + }, }); -} -async function scrubLegacyKeys(): Promise { - try { - const db = await openDB(); - try { - const scrubbed = await scrubLegacySpendingMaterial(db, STORE_NAME); - if (scrubbed > 0) - console.log('[app-sw] Removed legacy key fields from ' + scrubbed + ' record(s)'); - } finally { - db.close(); - } - } catch (error) { - console.error('[app-sw] Legacy key scrub failed:', error); + if (events.length > 0) { + console.log(`[app-sw] Found ${events.length} events for ${storedKey.publicKey}`); + // TODO: decrypt and scan with Wraith SDK when bundled in SW context } } @@ -321,7 +287,6 @@ self.addEventListener('activate', (event) => { event.waitUntil( Promise.all([ self.clients.claim(), - scrubLegacyKeys(), // Drop every cache this app owns that is not part of the running version: // superseded versions, newer versions left behind by a roll-back, and // caches written by installs that predate explicit versioning. @@ -379,8 +344,6 @@ self.addEventListener('notificationclick', (event) => { // ── Message handler ──────────────────────────────────────────────────────────── self.addEventListener('message', (event) => { - const { type, publicKey } = event.data; - const forbidden = findSpendingSecretField(event.data); if (forbidden) { console.error('[app-sw] Dropped message: "' + forbidden + '" must never reach the worker'); @@ -390,9 +353,6 @@ self.addEventListener('message', (event) => { }); return; } - - if (type === 'SKIP_WAITING') { - self.skipWaiting(); const validation = validateMainThreadMessage(event.data); if (!validation.valid) { console.warn('[app-sw] Ignoring invalid message from main thread:', validation.reason); @@ -401,30 +361,6 @@ self.addEventListener('message', (event) => { const msg = validation.data; - if (type === 'REGISTER_VIEWING_KEY') { - event.waitUntil( - (async () => { - try { - if (typeof publicKey !== 'string' || publicKey.length === 0) { - throw new Error('Missing publicKey'); - } - const db = await openDB(); - try { - await upsertAccountRecord(db, STORE_NAME, publicKey); - } finally { - db.close(); - } - (event.source as Client)?.postMessage({ type: 'VIEWING_KEY_REGISTERED' }); - } catch (error) { - console.error('[app-sw] Failed to register viewing key:', error); - (event.source as Client)?.postMessage({ - type: 'VIEWING_KEY_ERROR', - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - })(), - ); - } switch (msg.type) { case 'SKIP_WAITING': self.skipWaiting(); @@ -434,32 +370,6 @@ self.addEventListener('message', (event) => { event.waitUntil( (async () => { const db = await openDB(); - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - await new Promise((resolve, reject) => { - const request = store.delete(publicKey); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - - await removeScanKey(createIdbScanKeyBackend(), publicKey); - - // Unregister periodic sync if no keys remain - const allKeys = await new Promise((resolve, reject) => { - const tx = db.transaction([STORE_NAME], 'readonly'); - const st = tx.objectStore(STORE_NAME); - const req = st.getAll(); - req.onerror = () => reject(req.error); - req.onsuccess = () => resolve(req.result || []); - }); - db.close(); - - if (allKeys.length === 0 && 'periodicSync' in self.registration) { - await ( - self.registration as ServiceWorkerRegistration & { - periodicSync: { unregister(tag: string): Promise }; - } - ).periodicSync.unregister(SYNC_TAG); try { const storedKey = await new Promise((resolve, reject) => { const request = db @@ -539,54 +449,6 @@ self.addEventListener('message', (event) => { request.onsuccess = () => resolve(); }); - // Store subscription info in IndexedDB - const db = await openDB(); - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - - // Update existing entry or create new one - const existing = await new Promise((resolve, reject) => { - const request = store.get(subscription.keys?.p256dh || 'default'); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - - const entry: StoredViewingKey = existing || { - publicKey: subscription.keys?.p256dh || 'default', - timestamp: Date.now(), - }; - - // Store relay URL and meta-address hash - (entry as any).relayUrl = relayUrl; - (entry as any).metaAddressHash = metaAddressHash; - (entry as any).pushSubscription = subscription; - - await new Promise((resolve, reject) => { - const request = store.put(entry); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - - db.close(); - (event.source as Client)?.postMessage({ type: 'PUSH_SUBSCRIPTION_REGISTERED' }); - } catch (error) { - console.error('[app-sw] Failed to register push subscription:', error); - (event.source as Client)?.postMessage({ - type: 'PUSH_SUBSCRIPTION_ERROR', - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - })(), - ); - } - - if (type === 'UNREGISTER_PUSH_SUBSCRIPTION') { - event.waitUntil( - (async () => { - try { - const { subscription, metaAddressHash } = event.data ?? {}; - if (!subscription) { - throw new Error('Missing subscription'); // Unregister periodic sync if no keys remain const allKeys = await new Promise((resolve, reject) => { const tx = db.transaction([STORE_NAME], 'readonly'); @@ -768,3 +630,6 @@ self.addEventListener('push', (event: PushEvent) => { }); export {}; +function findSpendingSecretField(data: any) { + throw new Error('Function not implemented.'); +}