Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .storybook/decorators/withStealthKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const baseValue: StealthKeysValue = {
solanaMetaAddress: null,
ckbKeys: null,
ckbMetaAddress: null,
isRecoveryMode: false,
isReadOnly: false,
setIsRecoveryMode: noop,
setIsReadOnly: noop,
restoreFromRecoveryKit: noop,
exitRecoveryMode: noop,
setEvmKeys: noop,
setEvmMetaAddress: noop,
setStellarKeys: noop,
Expand Down
7 changes: 7 additions & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import { LocaleSwitcher } from './LocaleSwitcher';
import { NetworkChip } from './NetworkChip';
import { useTheme } from '@/context/ThemeContext';
import { useNotificationsStore } from '@/stores/notificationsStore';
import { useStealthKeys } from '@/context/StealthKeysContext';

export function Header() {
const location = useLocation();
const { t } = useTranslation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { theme, toggleTheme } = useTheme();
const unreadCount = useNotificationsStore((state) => state.unreadCount());
const { isRecoveryMode } = useStealthKeys();

const navLinks = [
{ to: '/send', label: t('nav.send') },
Expand All @@ -35,6 +37,11 @@ export function Header() {
<span className="bg-surface-bright px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider text-outline sm:text-[10px]">
Demo
</span>
{isRecoveryMode && (
<span className="border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-wider text-amber-400 sm:text-[10px]">
Recovery Mode
</span>
)}
</Link>

<nav className="hidden gap-0 sm:flex">
Expand Down
2 changes: 1 addition & 1 deletion src/components/StellarReceive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,7 @@ export function StellarReceive() {
}
}, [notifications]);

if (!isConnected) {
if (!isConnected && !stellarKeys) {
return (
<section className="flex flex-col gap-3">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
Expand Down
127 changes: 124 additions & 3 deletions src/context/StealthKeysContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { StealthKeys as EVMStealthKeys } from '@wraith-protocol/sdk/chains/
import type { StealthKeys as StellarStealthKeys } from '@wraith-protocol/sdk/chains/stellar';
import type { StealthKeys as SolanaStealthKeys } from '@wraith-protocol/sdk/chains/solana';
import type { StealthKeys as CKBStealthKeys } from '@wraith-protocol/sdk/chains/ckb';
import { hexToBytes, type RecoveryKitData } from '@/lib/stellar/recoveryKit';
import { importLabels } from '@/lib/stealthLabels';

interface StealthKeysContextValue {
evmKeys: EVMStealthKeys | null;
Expand All @@ -14,6 +16,12 @@ interface StealthKeysContextValue {
solanaMetaAddress: string | null;
ckbKeys: CKBStealthKeys | null;
ckbMetaAddress: string | null;
isRecoveryMode: boolean;
isReadOnly: boolean;
setIsRecoveryMode: (active: boolean) => void;
setIsReadOnly: (readOnly: boolean) => void;
restoreFromRecoveryKit: (kitData: RecoveryKitData) => void;
exitRecoveryMode: () => void;
setEvmKeys: (keys: EVMStealthKeys) => void;
setEvmMetaAddress: (metaAddress: string) => void;
setStellarKeys: (keys: StellarStealthKeys) => void;
Expand Down Expand Up @@ -51,22 +59,129 @@ export function StealthKeysProvider({ children }: { children: React.ReactNode })
const [solanaMetaAddress, setSolanaMetaAddress] = useState<string | null>(null);
const [ckbKeys, setCkbKeys] = useState<CKBStealthKeys | null>(null);
const [ckbMetaAddress, setCkbMetaAddress] = useState<string | null>(null);
const [isRecoveryMode, setIsRecoveryMode] = useState(false);
const [isReadOnly, setIsReadOnly] = useState(false);

const clearEvm = useCallback(() => {
if (isRecoveryMode) return;
setEvmKeys(null);
setEvmMetaAddress(null);
}, []);
}, [isRecoveryMode]);

const clearStellar = useCallback(() => {
if (isRecoveryMode) return;
setStellarKeys(null);
setStellarMetaAddress(null);
}, []);
}, [isRecoveryMode]);

const clearSolana = useCallback(() => {
if (isRecoveryMode) return;
setSolanaKeys(null);
setSolanaMetaAddress(null);
}, []);
}, [isRecoveryMode]);

const clearCkb = useCallback(() => {
if (isRecoveryMode) return;
setCkbKeys(null);
setCkbMetaAddress(null);
}, [isRecoveryMode]);

const exitRecoveryMode = useCallback(() => {
setIsRecoveryMode(false);
setIsReadOnly(false);
setEvmKeys(null);
setEvmMetaAddress(null);
setStellarKeys(null);
setStellarMetaAddress(null);
setSolanaKeys(null);
setSolanaMetaAddress(null);
setCkbKeys(null);
setCkbMetaAddress(null);
}, []);

const restoreFromRecoveryKit = useCallback((kitData: RecoveryKitData) => {
setIsRecoveryMode(true);
setIsReadOnly(!kitData.spendingScalarHex);

const viewingKey = kitData.viewingScalarHex
? hexToBytes(kitData.viewingScalarHex)
: new Uint8Array(32);
const spendingPubKey = kitData.spendingPubKeyHex
? hexToBytes(kitData.spendingPubKeyHex)
: new Uint8Array(32);
const viewingPubKey = kitData.viewingPubKeyHex
? hexToBytes(kitData.viewingPubKeyHex)
: new Uint8Array(32);
const spendingScalar = kitData.spendingScalarHex
? BigInt(
kitData.spendingScalarHex.startsWith('0x')
? kitData.spendingScalarHex
: `0x${kitData.spendingScalarHex}`,
)
: undefined;

const chain = kitData.chain?.toLowerCase() || '';

if (chain === 'stellar' || kitData.metaAddress?.startsWith('st:xlm:')) {
const keys: any = {
viewingKey,
viewingScalar: viewingKey,
spendingKey: spendingPubKey,
spendingPubKey,
viewingPubKey,
spendingScalar,
};
setStellarKeys(keys as StellarStealthKeys);
setStellarMetaAddress(kitData.metaAddress);
} else if (chain === 'horizen' || kitData.metaAddress?.startsWith('st:eth:')) {
const keys: any = {
viewingKey: kitData.viewingScalarHex,
spendingPubKey: kitData.spendingPubKeyHex || '',
viewingPubKey: kitData.viewingPubKeyHex || '',
spendingScalar: kitData.spendingScalarHex || '',
};
setEvmKeys(keys as EVMStealthKeys);
setEvmMetaAddress(kitData.metaAddress);
} else if (chain === 'solana' || kitData.metaAddress?.startsWith('st:sol:')) {
const keys: any = {
viewingKey,
viewingScalar: viewingKey,
spendingKey: spendingPubKey,
spendingPubKey,
viewingPubKey,
spendingScalar,
};
setSolanaKeys(keys as SolanaStealthKeys);
setSolanaMetaAddress(kitData.metaAddress);
} else if (chain === 'ckb' || kitData.metaAddress?.startsWith('st:ckb:')) {
const keys: any = {
viewingKey: kitData.viewingScalarHex,
spendingPubKey: kitData.spendingPubKeyHex || '',
viewingPubKey: kitData.viewingPubKeyHex || '',
spendingScalar: kitData.spendingScalarHex || '',
};
setCkbKeys(keys as CKBStealthKeys);
setCkbMetaAddress(kitData.metaAddress);
} else {
const keys: any = {
viewingKey,
viewingScalar: viewingKey,
spendingKey: spendingPubKey,
spendingPubKey,
viewingPubKey,
spendingScalar,
};
setStellarKeys(keys as StellarStealthKeys);
setStellarMetaAddress(kitData.metaAddress);
}

if (kitData.labels) {
try {
importLabels(kitData.metaAddress, JSON.stringify(kitData.labels), true);
} catch {
// Labels restore optional
}
}
}, []);

return (
Expand All @@ -80,6 +195,12 @@ export function StealthKeysProvider({ children }: { children: React.ReactNode })
solanaMetaAddress,
ckbKeys,
ckbMetaAddress,
isRecoveryMode,
isReadOnly,
setIsRecoveryMode,
setIsReadOnly,
restoreFromRecoveryKit,
exitRecoveryMode,
setEvmKeys,
setEvmMetaAddress,
setStellarKeys,
Expand Down
83 changes: 83 additions & 0 deletions src/lib/stellar/recoveryKit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import {
validatePassphrase,
generateRecoveryFilename,
exportRecoveryKit,
importRecoveryKit,
} from './recoveryKit';

describe('recoveryKit', () => {
it('validates passphrase strength and blocks weak passphrases (< 8 chars)', () => {
const weak1 = validatePassphrase('short');
expect(weak1.valid).toBe(false);
expect(weak1.score).toBe(0);
expect(weak1.message).toContain('at least 8 characters');

const weak2 = validatePassphrase('1234567');
expect(weak2.valid).toBe(false);

const valid = validatePassphrase('securePass123!');
expect(valid.valid).toBe(true);
expect(valid.score).toBeGreaterThanOrEqual(3);
});

it('generates filename including meta-address prefix', () => {
const filename = generateRecoveryFilename('st:xlm:0x1234567890abcdef');
expect(filename).toContain('wraith-recovery-kit-1234567890ab-');
expect(filename.endsWith('.json')).toBe(true);
});

it('round-trips export and import encrypted kit', async () => {
const options = {
passphrase: 'super-secret-passphrase-123',
chain: 'stellar',
metaAddress: 'st:xlm:0xabcdef1234567890',
viewingScalarHex: '0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20',
viewingPubKeyHex: '2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40',
spendingPubKeyHex: '4142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60',
spendingScalarHex: '6162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f80',
labels: {
'0x123': { label: 'Test Payment', tags: ['vip'] },
},
};

const encryptedKit = await exportRecoveryKit(options);
expect(encryptedKit.version).toBe(1);
expect(encryptedKit.salt).toBeDefined();
expect(encryptedKit.iv).toBeDefined();
expect(encryptedKit.ciphertext).toBeDefined();

const restored = await importRecoveryKit(encryptedKit, 'super-secret-passphrase-123');
expect(restored.chain).toBe('stellar');
expect(restored.metaAddress).toBe('st:xlm:0xabcdef1234567890');
expect(restored.viewingScalarHex).toBe(options.viewingScalarHex);
expect(restored.viewingPubKeyHex).toBe(options.viewingPubKeyHex);
expect(restored.spendingPubKeyHex).toBe(options.spendingPubKeyHex);
expect(restored.spendingScalarHex).toBe(options.spendingScalarHex);
expect(restored.labels).toEqual(options.labels);
});

it('throws error when exporting with weak passphrase (< 8 chars)', async () => {
await expect(
exportRecoveryKit({
passphrase: 'weak',
chain: 'stellar',
metaAddress: 'st:xlm:0x123',
viewingScalarHex: '010203',
}),
).rejects.toThrow('Passphrase must be at least 8 characters long');
});

it('throws error when importing with wrong passphrase', async () => {
const encryptedKit = await exportRecoveryKit({
passphrase: 'correct-passphrase-123',
chain: 'stellar',
metaAddress: 'st:xlm:0x123',
viewingScalarHex: '0102030405',
});

await expect(importRecoveryKit(encryptedKit, 'wrong-passphrase-456')).rejects.toThrow(
'Failed to decrypt recovery kit',
);
});
});
Loading
Loading