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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/components/StellarReceive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1314,19 +1314,29 @@ 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();
if (address) {
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 {
Expand Down
115 changes: 115 additions & 0 deletions src/context/StealthKeysContext.lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useStealthKeys>;
function Probe() {
ctx = useStealthKeys();
return null;
}

function mountApp() {
return render(
<StellarWalletContext.Provider value={wallet as never}>
<StealthKeysProvider>
<Probe />
</StealthKeysProvider>
</StellarWalletContext.Provider>,
);
}

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));
});
});
3 changes: 3 additions & 0 deletions src/context/StealthKeysContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +73,7 @@ export function StealthKeysProvider({ children }: { children: React.ReactNode })
if (isRecoveryMode) return;
setStellarKeys(null);
setStellarMetaAddress(null);
void lockStellarScanKeys();
}, [isRecoveryMode]);

const clearSolana = useCallback(() => {
Expand All @@ -97,6 +99,7 @@ export function StealthKeysProvider({ children }: { children: React.ReactNode })
setSolanaMetaAddress(null);
setCkbKeys(null);
setCkbMetaAddress(null);
void lockStellarScanKeys();
}, []);

const restoreFromRecoveryKit = useCallback((kitData: RecoveryKitData) => {
Expand Down
Loading