diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml index 95c563d8..abbc83b8 100644 --- a/.github/workflows/mobile-e2e.yml +++ b/.github/workflows/mobile-e2e.yml @@ -41,6 +41,7 @@ jobs: distribution: temurin java-version: '17' + - uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 # v4 no longer installs the removed `tools` SDK package (v3.2.2 ran # `sdkmanager tools`, which now fails with "Failed to find package # 'tools'"); it defaults to `platform-tools` instead. diff --git a/frontend/mobile/app/login.tsx b/frontend/mobile/app/login.tsx index 7713dfc7..22e41ed4 100644 --- a/frontend/mobile/app/login.tsx +++ b/frontend/mobile/app/login.tsx @@ -31,6 +31,7 @@ export default function LoginScreen() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [walletAddress, setWalletAddress] = useState(''); const [showAddress, setShowAddress] = useState(false); const [address, setAddress] = useState(''); @@ -52,6 +53,8 @@ export default function LoginScreen() { setBusy(true); setError(null); try { + await loginWithAddress(walletAddress); + router.replace('/dashboard'); const result = await loginWithAddress(address); router.replace('/dashboard'); void result; @@ -94,6 +97,28 @@ export default function LoginScreen() { )} + + [styles.ctaSecondary, busy && styles.disabled, pressed && styles.pressed]} + > + {busy ? : Sign in with address} + + disabled: { opacity: 0.5 }, pressed: { opacity: 0.85 }, ctaText: { color: colors.onAccent, fontFamily: fontFamily.bodySemiBold, fontSize: 15 }, + addressInput: { + color: colors.textPrimary, + fontFamily: fontFamily.address, + fontSize: 13, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surfaceMd, + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 13, + marginTop: 18, + }, + ctaSecondary: { + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.accent, + borderRadius: 100, + paddingVertical: 15, + }, + ctaSecondaryText: { color: colors.accent, fontFamily: fontFamily.bodySemiBold, fontSize: 14 }, card: { backgroundColor: colors.surface, borderWidth: 1, diff --git a/frontend/mobile/jest.config.js b/frontend/mobile/jest.config.js index 0fc7d64f..e99b558c 100644 --- a/frontend/mobile/jest.config.js +++ b/frontend/mobile/jest.config.js @@ -17,6 +17,9 @@ const expoPreset = require('jest-expo/jest-preset'); */ module.exports = { ...expoPreset, + // Shared SDK source is imported from the sibling package, so resolve its + // runtime dependencies from this app's plain npm install. + modulePaths: ['/node_modules'], setupFiles: [...expoPreset.setupFiles, '/jest.setup.js'], modulePaths: ['/node_modules'], transformIgnorePatterns: expoPreset.transformIgnorePatterns.map((pattern) => diff --git a/frontend/mobile/lib/__tests__/signerVerification.test.ts b/frontend/mobile/lib/__tests__/signerVerification.test.ts new file mode 100644 index 00000000..ee6e8d98 --- /dev/null +++ b/frontend/mobile/lib/__tests__/signerVerification.test.ts @@ -0,0 +1,28 @@ +import { recoverWalletByAddress } from '../../../../sdk/src/recovery/signerVerification'; +import { WalletContractNotFoundError } from '../../../../sdk/src/recovery/signerErrors'; +import { ADDRESS_RECOVERY_CASES } from '../../../../sdk/tests/fixtures/addressRecoveryCases'; + +const REGISTERED = '04' + '11'.repeat(64); +const UNREGISTERED = '04' + '22'.repeat(64); + +describe('mobile recovery uses the shared signer table', () => { + it.each(ADDRESS_RECOVERY_CASES)('$name', async (testCase) => { + const resolveSigners = jest.fn(async () => { + if (testCase.resolution === 'not-found') throw new WalletContractNotFoundError(testCase.address); + if (testCase.resolution === 'network-error') throw new Error('RPC unavailable'); + return testCase.resolution === 'empty' ? [] : [REGISTERED]; + }); + const authenticate = jest.fn(async () => testCase.authentication === 'registered' ? REGISTERED : UNREGISTERED); + + if (testCase.expected === 'accepted') { + await expect(recoverWalletByAddress(testCase.address, { resolveSigners, authenticate })).resolves.toMatchObject({ + address: testCase.address, + publicKey: REGISTERED, + }); + } else { + await expect(recoverWalletByAddress(testCase.address, { resolveSigners, authenticate })) + .rejects.toMatchObject({ name: testCase.expected }); + } + expect(resolveSigners).toHaveBeenCalledTimes(testCase.expected === 'InvalidWalletAddressError' ? 0 : 1); + }); +}); \ No newline at end of file diff --git a/frontend/mobile/lib/passkey.ts b/frontend/mobile/lib/passkey.ts index 608f9659..542b5b15 100644 --- a/frontend/mobile/lib/passkey.ts +++ b/frontend/mobile/lib/passkey.ts @@ -225,6 +225,41 @@ export function nativePrfEvaluator(credentialId: string): (salt: Uint8Array) => return async (salt: Uint8Array) => (await evaluatePrf(credentialId, salt)).output; } +export type DiscoveredPasskeyAssertion = { + credentialId: string; + authenticatorData: Uint8Array; + clientDataJSON: Uint8Array; + signature: Uint8Array; + prf: Uint8Array | null; +}; + +/** Discover a passkey once, retaining the assertion fields used by shared recovery verification. */ +export async function discoverPasskeyAssertion(salt: Uint8Array): Promise { + try { + const assertion = await passkeys().get({ + challenge: uint8ArrayToBase64Url(Crypto.getRandomBytes(32)), + rpId: getRelyingPartyId(), + userVerification: 'required', + timeout: 60_000, + extensions: { prf: { eval: { first: uint8ArrayToBase64Url(salt) } } }, + }); + if (!assertion) return null; + const credentialId = (assertion as { id?: string; rawId?: string }).id + ?? (assertion as { id?: string; rawId?: string }).rawId; + if (!credentialId) return null; + return { + credentialId, + authenticatorData: base64UrlToUint8Array(assertion.response.authenticatorData), + clientDataJSON: base64UrlToUint8Array(assertion.response.clientDataJSON), + signature: base64UrlToUint8Array(assertion.response.signature), + prf: parsePrfOutput(assertion), + }; + } catch (error: unknown) { + if (isUserRejection(error)) return null; + throw error; + } +} + /** * A discovered assertion: one pic-and-tap passkey gesture, plus the pieces of * the resulting WebAuthn assertion that let a caller verify the credential's diff --git a/frontend/mobile/lib/passkeyLogin.ts b/frontend/mobile/lib/passkeyLogin.ts index eecc1010..f3b5647b 100644 --- a/frontend/mobile/lib/passkeyLogin.ts +++ b/frontend/mobile/lib/passkeyLogin.ts @@ -4,6 +4,9 @@ import { sha256 } from '@noble/hashes/sha2.js'; import { Keypair, StrKey } from '@stellar/stellar-sdk'; import { Buffer } from 'buffer'; +import { discoverPasskeyAssertion, discoverWithPrf, nativePrfEvaluator } from './passkey'; +import { recoverWalletByAddress, matchWebAuthnSigner } from '../../../sdk/src/recovery/signerVerification'; +import { getNetwork, getNetworkName } from './network'; import { recordFeePayerSource } from './feePayerSource'; import { getNetworkName } from './network'; import { discoverWithPrf, nativePrfEvaluator, type DiscoveredPasskey } from './passkey'; @@ -141,6 +144,52 @@ export async function loginWithPasskey(): Promise { return { address: crumbs.walletAddress, source: 'recovered' }; } +/** Recover by a supplied wallet address using the shared validate/resolve/verify sequence. */ +export async function loginWithAddress(address: string): Promise { + const pickedRef = { value: null as Awaited> }; + const network = getNetwork(); + const result = await recoverWalletByAddress(address, { + rpcUrl: network.rpcUrl, + networkPassphrase: network.networkPassphrase, + authenticate: async (signers) => { + pickedRef.value = await discoverPasskeyAssertion(FEE_PAYER_PRF_SALT); + if (!pickedRef.value) throw new Error('Passkey prompt was cancelled.'); + return matchWebAuthnSigner(signers, pickedRef.value); + }, + }); + const picked = pickedRef.value; + if (!picked) throw new Error('Passkey sign-in was cancelled.'); + + const networkSuffix = getNetworkName() === 'mainnet' ? '_mainnet' : ''; + const [existingSecret, existingAddress, existingSdkAddress] = await Promise.all([ + getSignerSecret().catch(() => null), + getWalletAddress().catch(() => null), + AsyncStorage.getItem(`${SDK_ADDRESS}${networkSuffix}`).catch(() => null), + ]); + if ((existingAddress || existingSdkAddress) && !existingSecret && (!picked.prf || picked.prf.length < 32)) { + throw new Error('This device has an existing wallet but its fee-payer key is missing. Sign in with the original passkey on a PRF-capable device or restore the fee-payer before continuing.'); + } + const feePayer = existingSecret + ? Keypair.fromSecret(existingSecret) + : picked.prf && picked.prf.length >= 32 + ? Keypair.fromRawEd25519Seed(Buffer.from(picked.prf.subarray(0, 32))) + : Keypair.random(); + const publicKeyHex = result.publicKey.toLowerCase(); + + await Promise.all([ + setWalletAddress(result.address), + setSignerSecret(feePayer.secret()), + setPasskeyCredential(picked.credentialId, publicKeyHex), + ]); + await AsyncStorage.multiSet([ + [`${SDK_ADDRESS}${networkSuffix}`, result.address], + [`${SDK_KEY_ID}${networkSuffix}`, picked.credentialId], + [`${SDK_PUBLIC_KEY}${networkSuffix}`, publicKeyHex], + ]); + void writeBreadcrumbs(feePayer.secret(), result.address, new Uint8Array(Buffer.from(publicKeyHex, 'hex'))) + .catch(() => undefined); + + return { address: result.address, source: 'recovered' }; /** * Sign in to a wallet the user knows only by its C-address. * diff --git a/frontend/mobile/lib/recovery.ts b/frontend/mobile/lib/recovery.ts index 1b281ee6..5edf0375 100644 --- a/frontend/mobile/lib/recovery.ts +++ b/frontend/mobile/lib/recovery.ts @@ -19,6 +19,7 @@ import type * as PasskeysModule from 'react-native-passkeys'; import { getNetwork, type VeilNetwork } from './network'; import { setPasskeyCredential, setWalletAddress } from './walletStore'; import { base64UrlToUint8Array, uint8ArrayToBase64Url } from './webauthn'; +import { isRegisteredSigner } from '../../../sdk/src/recovery/signerRegistry'; let cachedPasskeys: typeof PasskeysModule | null | undefined; function passkeys(): typeof PasskeysModule { @@ -462,9 +463,8 @@ export async function walletHasSigner( publicKey: Uint8Array, network: VeilNetwork = getNetwork() ): Promise { - const target = toHex(publicKey); const signers = await fetchWalletSigners(walletAddress, network); - return signers.some((signer) => toHex(signer) === target); + return isRegisteredSigner(signers, publicKey); } // ── Recovery transactions ───────────────────────────────────────────────────── diff --git a/frontend/mobile/lib/signers.ts b/frontend/mobile/lib/signers.ts index 42569e05..57015da3 100644 --- a/frontend/mobile/lib/signers.ts +++ b/frontend/mobile/lib/signers.ts @@ -22,6 +22,8 @@ import { } from '@stellar/stellar-sdk'; import { getNetwork } from './network'; +import { NotVeilWalletError, WalletContractNotFoundError } from '../../../sdk/src/recovery/signerErrors'; +export { WalletContractNotFoundError } from '../../../sdk/src/recovery/signerErrors'; export type WalletSigner = { /** The signer's slot in the contract's signer map. */ @@ -34,22 +36,6 @@ function toHex(bytes: Uint8Array): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } -/** - * Raised by {@link readSigners} when the RPC answered but no `get_signers` - * instance exists at the address. Distinct from the network being unreachable, - * which surfaces as whatever the RPC threw instead — callers catch this type - * to tell the two apart. - */ -export class WalletContractNotFoundError extends Error { - readonly contractAddress: string; - - constructor(contractAddress: string) { - super(`No wallet contract is deployed at ${contractAddress} on this network.`); - this.name = 'WalletContractNotFoundError'; - this.contractAddress = contractAddress; - } -} - /** * Simulation diagnostics that mean "there is no contract here" rather than * "the call failed". Anything else coming back as a simulation error is left @@ -86,7 +72,7 @@ export async function readSigners(contractAddress: string): Promise(null) const [copied, setCopied] = useState(false) const [hasFeePayerKey, setHasFeePayerKey] = useState(true) + const [feePayerFunding, setFeePayerFunding] = useState<{ address: string; balance: number; required: number } | null>(null) const [agentBadge, setAgentBadge] = useState(false) const [contractXlm, setContractXlm] = useState(() => cachedContractXlm ?? 0) const [isSweeping, setIsSweeping] = useState(false) @@ -313,6 +315,8 @@ function DashboardPageContent() { // Track whether fee-payer exists so we can show a recovery banner setHasFeePayerKey(!!signerPublicKey) + const requiredFeePayerXlm = 1.01 + setFeePayerFunding(signerPublicKey ? { address: signerPublicKey, balance: 0, required: requiredFeePayerXlm } : null) let feePayerXlm = 0 let otherAssets: WalletAsset[] = [] @@ -342,6 +346,7 @@ function DashboardPageContent() { horizonNextRef.current = paymentsPage.records.length >= 20 ? paymentsPage.next : null txRecords = mapHorizonOps(paymentsPage.records as HorizonOp[], signerPublicKey) } catch { /* not yet funded */ } + setFeePayerFunding({ address: signerPublicKey, balance: feePayerXlm, required: requiredFeePayerXlm }) } // ── 3. Wraith: incoming SAC transfers to the wallet contract ──────────── @@ -786,6 +791,25 @@ function DashboardPageContent() { )} + {!loading && feePayerFunding && feePayerFunding.balance < feePayerFunding.required && ( +
+ +
+

Fee-payer needs funding

+

+ Send at least {feePayerFunding.required.toFixed(2)} XLM to this G... account. It pays network fees; it is separate from your wallet balance. +

+ +
+
+ )} + {/* ── PRF downgrade warning banner (issue #629) ── */} {!loading && showPrfDowngrade && !prfDowngradeDismissed && (
Your biometric is your key — no password needed.

+
diff --git a/frontend/wallet/app/page.tsx b/frontend/wallet/app/page.tsx index 80a4f752..41d957fa 100644 --- a/frontend/wallet/app/page.tsx +++ b/frontend/wallet/app/page.tsx @@ -166,6 +166,9 @@ export default function OnboardingPage() { + {error && (

{error} diff --git a/frontend/wallet/app/recover/page.tsx b/frontend/wallet/app/recover/page.tsx index 0d9b60a8..7555d1d5 100644 --- a/frontend/wallet/app/recover/page.tsx +++ b/frontend/wallet/app/recover/page.tsx @@ -1,17 +1,15 @@ 'use client' import { NetworkSwitcher } from '@/components/NetworkSwitcher' -import { inclusionFee } from '@/lib/fees' import { useState } from 'react' import { useRouter } from 'next/navigation' import { VeilMark } from '@/components/ui/VeilMark' -import { derToRawSignature, bufferToHex, hexToUint8Array } from '@veil/utils' -import { ensureFeePayer, resetFeePayer } from '@/lib/feePayer' +import { bufferToHex } from '@veil/utils' +import { matchWebAuthnSigner, recoverWalletByAddress } from '@veil/sdk/recovery/signerVerification' +import { establishRecoveredFeePayer } from '@/lib/feePayer' import { getNetwork } from '@/lib/network' -import { - rpc as SorobanRpc, Contract, TransactionBuilder, BASE_FEE, - Account, Keypair, scValToNative, -} from '@stellar/stellar-sdk' +import { FEE_PAYER_PRF_SALT } from '@veil/prf' +import { Keypair, StrKey } from '@stellar/stellar-sdk' import { deriveP256KeyPair } from '@/lib/recovery' import { walletLocal, walletSession } from '@/lib/walletStorage' @@ -29,7 +27,7 @@ export default function RecoverPage() { async function handleRecover() { const walletAddress = walletInput.trim() - if (!walletAddress.startsWith('C') || walletAddress.length !== 56) { + if (!StrKey.isValidContract(walletAddress)) { setError('Enter a valid C... wallet address.') return } @@ -38,121 +36,48 @@ export default function RecoverPage() { setStep('authenticating') try { - const server = new SorobanRpc.Server(network.rpcUrl) - - // ── 1. Fetch on-chain signers ──────────────────────────────────────── - const dummyKp = Keypair.random() - const sourceAcct = new Account(dummyKp.publicKey(), '0') - const walletContract = new Contract(walletAddress) - - const tx = new TransactionBuilder(sourceAcct, { - fee: inclusionFee(), + const assertionRef = { value: null as PublicKeyCredential | null } + const recovered = await recoverWalletByAddress(walletAddress, { + rpcUrl: network.rpcUrl, networkPassphrase: network.networkPassphrase, - }) - .addOperation(walletContract.call('get_signers')) - .setTimeout(30) - .build() - - const sim = await server.simulateTransaction(tx) - if (SorobanRpc.Api.isSimulationError(sim)) { - throw new Error(`Could not read this wallet on ${network.displayName}. A wallet exists on one network only — if it was created on the other, switch above and try again.`) - } - - const simResult = (sim as SorobanRpc.Api.SimulateTransactionSuccessResponse).result - if (!simResult) throw new Error('No result from contract simulation.') - - // Parse the XDR ScVal directly — avoids scValToNative runtime differences - // get_signers returns Map> → SCV_MAP of (SCV_U32, SCV_BYTES) entries - let publicKeys: Uint8Array[] = [] - try { - const entries = simResult.retval.map() as Array<{ val: () => { bytes: () => Buffer } }> - publicKeys = entries.map(e => new Uint8Array(e.val().bytes())) - } catch { - // Fallback: scValToNative handles all possible return shapes - const raw = scValToNative(simResult.retval) - if (Array.isArray(raw)) { - publicKeys = raw as Uint8Array[] - } else if (raw instanceof Map) { - publicKeys = Array.from((raw as Map).values()) - } else { - publicKeys = Object.values(raw as Record) - } - } - if (publicKeys.length === 0) throw new Error('No signers found on this wallet.') - - // ── 2. Discoverable passkey assertion ──────────────────────────────── - // Empty allowCredentials lets the OS show ALL available passkeys so - // the user can pick the right one even on a new device. - const challenge = crypto.getRandomValues(new Uint8Array(32)) - const assertion = await navigator.credentials.get({ - publicKey: { - challenge, - allowCredentials: [], - userVerification: 'required', + authenticate: async (signers) => { + assertionRef.value = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [], + userVerification: 'required', + extensions: { prf: { eval: { first: FEE_PAYER_PRF_SALT.buffer.slice(0) } } } as AuthenticationExtensionsClientInputs, + }, + }) as PublicKeyCredential | null + if (!assertionRef.value) throw new Error('Passkey prompt was cancelled.') + const response = assertionRef.value.response as AuthenticatorAssertionResponse + return matchWebAuthnSigner(signers, { + authenticatorData: response.authenticatorData, + clientDataJSON: response.clientDataJSON, + signature: response.signature, + }) }, - }) as PublicKeyCredential | null - + }) + const assertion = assertionRef.value if (!assertion) throw new Error('Passkey prompt was cancelled.') - - const response = assertion.response as AuthenticatorAssertionResponse - const authData = new Uint8Array(response.authenticatorData) - const clientDataJSON = new Uint8Array(response.clientDataJSON) - const sigDer = new Uint8Array(response.signature) - const rawSig = derToRawSignature(sigDer.buffer.slice(sigDer.byteOffset, sigDer.byteOffset + sigDer.byteLength) as ArrayBuffer) - - // ── 3. Verify signature against each on-chain public key ───────────── - // WebAuthn signed: SHA-256(authData || SHA-256(clientDataJSON)) - // SubtleCrypto ECDSA hashes internally so we pass authData || SHA-256(clientDataJSON) - const clientDataHash = new Uint8Array( - await crypto.subtle.digest('SHA-256', clientDataJSON.buffer as ArrayBuffer) - ) - const message = new Uint8Array([...authData, ...clientDataHash]) - - let matchedHex: string | null = null - - for (const pubKeyBytes of publicKeys) { - try { - const cryptoKey = await crypto.subtle.importKey( - 'raw', - pubKeyBytes.buffer as ArrayBuffer, - { name: 'ECDSA', namedCurve: 'P-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - { name: 'ECDSA', hash: { name: 'SHA-256' } }, - cryptoKey, - rawSig.buffer as ArrayBuffer, - message.buffer as ArrayBuffer - ) - if (valid) { - matchedHex = bufferToHex(pubKeyBytes) - break - } - } catch { - // Try next key - } - } - - if (!matchedHex) { - throw new Error( - 'This passkey does not match any signer on this wallet. Make sure you are using the correct passkey and wallet address.' - ) - } + const matchedHex = recovered.publicKey + const prfResult = (assertion.getClientExtensionResults() as { + prf?: { results?: { first?: ArrayBuffer | ArrayBufferView } } + }).prf?.results?.first + const prf = prfResult + ? new Uint8Array(prfResult instanceof ArrayBuffer + ? prfResult + : prfResult.buffer.slice(prfResult.byteOffset, prfResult.byteOffset + prfResult.byteLength)) + : null + + // Preserve any existing fee-payer instead of deleting it on recovery. + await establishRecoveredFeePayer(prf, assertion.id) // ── 4. Restore localStorage + session ──────────────────────────────── - walletLocal.setItem('invisible_wallet_address', walletAddress) + walletLocal.setItem('invisible_wallet_address', recovered.address) walletLocal.setItem('invisible_wallet_key_id', assertion.id) walletLocal.setItem('invisible_wallet_public_key', matchedHex) - walletSession.setItem('invisible_wallet_address', walletAddress) - - // Re-establish the fee-payer for the recovered wallet. Using the same - // credential reconstructs the same key: a PRF-capable credential yields the - // PRF-derived fee-payer, a legacy one the credential-ID derivation — - // matching whichever mode the wallet was created with (ADR 0003). Clear any - // stale state from a prior wallet on this device first. - resetFeePayer() - await ensureFeePayer() + walletSession.setItem('invisible_wallet_address', recovered.address) setStep('done') setTimeout(() => router.push('/dashboard'), 800) @@ -170,7 +95,7 @@ export default function RecoverPage() { async function handlePaperRecover() { const walletAddress = walletInput.trim() - if (!walletAddress.startsWith('C') || walletAddress.length !== 56) { + if (!StrKey.isValidContract(walletAddress)) { setError('Enter a valid C... wallet address.') return } @@ -184,66 +109,27 @@ export default function RecoverPage() { setStep('authenticating') try { - const server = new SorobanRpc.Server(network.rpcUrl) - const dummyKp = Keypair.random() - const sourceAcct = new Account(dummyKp.publicKey(), '0') - const walletContract = new Contract(walletAddress) - - const tx = new TransactionBuilder(sourceAcct, { - fee: inclusionFee(), - networkPassphrase: network.networkPassphrase, - }) - .addOperation(walletContract.call('get_signers')) - .setTimeout(30) - .build() - - const sim = await server.simulateTransaction(tx) - if (SorobanRpc.Api.isSimulationError(sim)) { - throw new Error(`Could not read this wallet on ${network.displayName}. A wallet exists on one network only — if it was created on the other, switch above and try again.`) - } - - const simResult = (sim as SorobanRpc.Api.SimulateTransactionSuccessResponse).result - if (!simResult) throw new Error('No result from contract simulation.') - - let publicKeys: Uint8Array[] = [] - try { - const entries = simResult.retval.map() as Array<{ val: () => { bytes: () => Buffer } }> - publicKeys = entries.map(e => new Uint8Array(e.val().bytes())) - } catch { - const raw = scValToNative(simResult.retval) - if (Array.isArray(raw)) { - publicKeys = raw as Uint8Array[] - } else if (raw instanceof Map) { - publicKeys = Array.from((raw as Map).values()) - } else { - publicKeys = Object.values(raw as Record) - } - } - if (publicKeys.length === 0) throw new Error('No signers found on this wallet.') - // Derive keypair from paper phrase const { publicKey, privateKey } = deriveP256KeyPair(mnemonic) const matchedHex = bufferToHex(publicKey) + const recovered = await recoverWalletByAddress(walletAddress, { + rpcUrl: network.rpcUrl, + networkPassphrase: network.networkPassphrase, + authenticate: async () => matchedHex, + }) - const isMatched = publicKeys.some(pubKeyBytes => bufferToHex(pubKeyBytes) === matchedHex) - if (!isMatched) { - throw new Error('This recovery phrase public key does not match any registered signer on this wallet.') - } + // Preserve any existing fee-payer instead of overwriting it on recovery. + const feePayerSeed = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(mnemonic)) + const recoveredFeePayer = Keypair.fromRawEd25519Seed(Buffer.from(new Uint8Array(feePayerSeed).slice(0, 32))) + await establishRecoveredFeePayer(null, 'recovery', false, recoveredFeePayer) // Store in storage - walletLocal.setItem('invisible_wallet_address', walletAddress) + walletLocal.setItem('invisible_wallet_address', recovered.address) walletLocal.setItem('invisible_wallet_key_id', 'recovery') walletLocal.setItem('invisible_wallet_public_key', matchedHex) - walletSession.setItem('invisible_wallet_address', walletAddress) + walletSession.setItem('invisible_wallet_address', recovered.address) walletSession.setItem('invisible_wallet_recovery_private_key', bufferToHex(privateKey)) - // Derive deterministic fee-payer from the mnemonic seed - const seed = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(mnemonic)) - const derivedFeePayer = Keypair.fromRawEd25519Seed(Buffer.from(new Uint8Array(seed).slice(0, 32))) - walletLocal.setItem('veil_signer_secret', derivedFeePayer.secret()) - walletLocal.setItem('veil_signer_public_key', derivedFeePayer.publicKey()) - walletSession.setItem('veil_signer_secret', derivedFeePayer.secret()) - setStep('done') setTimeout(() => router.push('/dashboard'), 800) } catch (err: unknown) { diff --git a/frontend/wallet/app/sign-in/page.tsx b/frontend/wallet/app/sign-in/page.tsx new file mode 100644 index 00000000..d30ee9f0 --- /dev/null +++ b/frontend/wallet/app/sign-in/page.tsx @@ -0,0 +1,5 @@ +import { AddressRecovery } from '@/components/AddressRecovery' + +export default function SignInPage() { + return +} \ No newline at end of file diff --git a/frontend/wallet/components/AddressRecovery.tsx b/frontend/wallet/components/AddressRecovery.tsx new file mode 100644 index 00000000..6073a587 --- /dev/null +++ b/frontend/wallet/components/AddressRecovery.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { AlertCircle } from 'lucide-react' +import { decryptBackup, deserializeBackup, type WalletBackupMetadata } from '@veil/backup' +import { matchWebAuthnSigner, recoverWalletByAddress } from '@veil/sdk/recovery/signerVerification' +import { FEE_PAYER_PRF_SALT } from '@veil/prf' +import { persistRestoredState } from '@/lib/backup' +import { establishRecoveredFeePayer, FeePayerConflictError } from '@/lib/feePayer' +import { getNetwork } from '@/lib/network' +import { walletLocal, walletSession } from '@/lib/walletStorage' +import { NetworkSwitcher } from '@/components/NetworkSwitcher' + +type DiscoveredAssertion = { + credentialId: string + authenticatorData: ArrayBuffer + clientDataJSON: ArrayBuffer + signature: ArrayBuffer + prf: Uint8Array | null +} + +function asArrayBuffer(value: ArrayBuffer | ArrayBufferView): ArrayBuffer { + if (value instanceof ArrayBuffer) return value + return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer +} + +async function getAssertion(): Promise { + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [], + userVerification: 'required', + extensions: { prf: { eval: { first: FEE_PAYER_PRF_SALT.buffer.slice(0) } } } as AuthenticationExtensionsClientInputs, + }, + }) as PublicKeyCredential | null + if (!assertion) return null + const credentialResponse = assertion.response as AuthenticatorAssertionResponse + const prfResult = (assertion.getClientExtensionResults() as { + prf?: { results?: { first?: ArrayBuffer | ArrayBufferView } } + }).prf?.results?.first + return { + credentialId: assertion.id, + authenticatorData: credentialResponse.authenticatorData, + clientDataJSON: credentialResponse.clientDataJSON, + signature: credentialResponse.signature, + prf: prfResult ? new Uint8Array(asArrayBuffer(prfResult)) : null, + } +} + +export function AddressRecovery() { + const router = useRouter() + const [address, setAddress] = useState('') + const [file, setFile] = useState(null) + const [passphrase, setPassphrase] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [confirmFeePayerReplacement, setConfirmFeePayerReplacement] = useState(false) + const [pendingBackup, setPendingBackup] = useState(undefined) + + async function recover(backup?: WalletBackupMetadata, replaceFeePayer = false) { + const selectedAddress = backup?.address ?? address.trim() + setError(null) + setConfirmFeePayerReplacement(false) + setBusy(true) + try { + const network = getNetwork() + if (backup?.networkPassphrase && backup.networkPassphrase !== network.networkPassphrase) { + throw new Error('This backup belongs to a different Stellar network. Switch networks and try again.') + } + const assertionRef = { value: null as DiscoveredAssertion | null } + const result = await recoverWalletByAddress(selectedAddress, { + rpcUrl: network.rpcUrl, + networkPassphrase: network.networkPassphrase, + authenticate: async (signers) => { + assertionRef.value = await getAssertion() + if (!assertionRef.value) throw new Error('Passkey prompt was cancelled.') + return matchWebAuthnSigner(signers, assertionRef.value) + }, + }) + const assertion = assertionRef.value + if (!assertion) throw new Error('Passkey prompt was cancelled.') + + await establishRecoveredFeePayer(assertion.prf, assertion.credentialId, replaceFeePayer) + if (backup) await persistRestoredState(backup) + walletLocal.setItem('invisible_wallet_address', result.address) + walletLocal.setItem('invisible_wallet_key_id', assertion.credentialId) + walletLocal.setItem('invisible_wallet_public_key', result.publicKey) + walletSession.setItem('invisible_wallet_address', result.address) + setPassphrase('') + router.replace('/dashboard') + } catch (cause) { + if (cause instanceof FeePayerConflictError) { + setPendingBackup(backup) + setError('A fee-payer from another wallet is stored in this browser. Replacing it removes that secret here and may make its funds inaccessible without another backup.') + setConfirmFeePayerReplacement(true) + return + } + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setBusy(false) + } + } + + async function restoreBackup() { + if (!file || !passphrase) { + setError('Choose a backup file and enter its passphrase.') + return + } + setError(null) + setBusy(true) + try { + const envelope = deserializeBackup(await file.text()) + const metadata = await decryptBackup(envelope, passphrase) + await recover(metadata) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + setBusy(false) + } + } + + return ( +

+
+ +
+

Sign in to your wallet

+

Use your wallet address or encrypted backup, then verify with a registered passkey.

+ {error && ( +
+ + {error} +
+ )} + {confirmFeePayerReplacement && ( + + )} + + +
+ + setPassphrase(event.target.value)} placeholder="Backup passphrase" autoComplete="current-password" /> + +
+
+
+ ) +} diff --git a/frontend/wallet/lib/__tests__/feePayer.test.ts b/frontend/wallet/lib/__tests__/feePayer.test.ts index dfa59976..a63f2b47 100644 --- a/frontend/wallet/lib/__tests__/feePayer.test.ts +++ b/frontend/wallet/lib/__tests__/feePayer.test.ts @@ -22,6 +22,8 @@ import { Keypair } from '@stellar/stellar-sdk' import type { PrfEvaluator } from '@veil/prf' import { ensureFeePayer, + establishRecoveredFeePayer, + FeePayerConflictError, peekFeePayerSecret, getFeePayerMode, clearFeePayer, @@ -121,6 +123,70 @@ describe('pre-existing wallet stays legacy (no address move)', () => { expect(prfCalled).toBe(false) expect(kp!.secret()).toBe(legacy.secret()) }) + + it('reuses an existing fee-payer during address recovery', async () => { + const fundedKey = Keypair.random() + localStorage.setItem(KEY_ID, CRED) + localStorage.setItem(SECRET, fundedKey.secret()) + localStorage.setItem('veil_feepayer_mode', 'prf-raw') + + const recovered = await establishRecoveredFeePayer(null, CRED) + + expect(recovered.secret()).toBe(fundedKey.secret()) + expect(getFeePayerMode()).toBe('prf-raw') + expect(localStorage.getItem(SECRET)).toBe(fundedKey.secret()) + }) + + it('refuses to replace a fee-payer bound to a different passkey and preserves it', async () => { + const fundedKey = Keypair.random() + localStorage.setItem(KEY_ID, CRED) + localStorage.setItem(SECRET, fundedKey.secret()) + localStorage.setItem('veil_feepayer_mode', 'legacy') + + await expect(establishRecoveredFeePayer(null, 'REPLACEMENT_CREDENTIAL')) + .rejects.toBeInstanceOf(FeePayerConflictError) + expect(localStorage.getItem(SECRET)).toBe(fundedKey.secret()) + }) + + it('refuses to reuse an unbound fee-payer when recovering another wallet', async () => { + const fundedKey = Keypair.random() + localStorage.setItem(SECRET, fundedKey.secret()) + + await expect(establishRecoveredFeePayer(null, CRED)) + .rejects.toBeInstanceOf(FeePayerConflictError) + expect(localStorage.getItem(SECRET)).toBe(fundedKey.secret()) + }) + + it('replaces an existing fee-payer only when explicitly requested', async () => { + const fundedKey = Keypair.random() + localStorage.setItem(KEY_ID, CRED) + localStorage.setItem(SECRET, fundedKey.secret()) + localStorage.setItem('veil_feepayer_mode', 'legacy') + + const recovered = await establishRecoveredFeePayer(null, 'REPLACEMENT_CREDENTIAL', true) + expect(recovered.secret()).not.toBe(fundedKey.secret()) + expect(localStorage.getItem(SECRET)).toBe(recovered.secret()) + }) + + it('creates a random legacy key only when no fee-payer exists', async () => { + localStorage.setItem(KEY_ID, CRED) + + const recovered = await establishRecoveredFeePayer(null) + + expect(recovered).toBeInstanceOf(Keypair) + expect(getFeePayerMode()).toBe('legacy') + expect(localStorage.getItem(SECRET)).toBe(recovered.secret()) + }) + + it('preserves the deterministic fee-payer supplied by paper recovery', async () => { + localStorage.setItem(KEY_ID, CRED) + const recoveredKey = Keypair.random() + + const result = await establishRecoveredFeePayer(null, 'recovery', false, recoveredKey) + + expect(result.secret()).toBe(recoveredKey.secret()) + expect(localStorage.getItem(SECRET)).toBe(recoveredKey.secret()) + }) }) describe('clearFeePayer', () => { diff --git a/frontend/wallet/lib/feePayer.ts b/frontend/wallet/lib/feePayer.ts index b1867c99..48e4adb8 100644 --- a/frontend/wallet/lib/feePayer.ts +++ b/frontend/wallet/lib/feePayer.ts @@ -46,6 +46,13 @@ const DIAGNOSTICS = 'veil_feepayer_diagnostics' */ export type FeePayerMode = 'prf-raw' | 'prf-hkdf' | 'legacy' +export class FeePayerConflictError extends Error { + constructor() { + super('A fee-payer for a different wallet is already stored in this browser.') + this.name = 'FeePayerConflictError' + } +} + /** Outcome of one candidate's on-chain existence probe (see {@link pickFundedCandidate}). */ export type FeePayerProbeStatus = 'exists' | 'not-found' | 'network-error' | 'not-probed' @@ -289,6 +296,74 @@ export async function ensureFeePayer(evaluator?: PrfEvaluator): Promise { + const existing = peekFeePayerSecret() + if (existing) { + const storedCredentialId = walletLocal.getItem(KEY_ID) + if (!replaceExisting) { + if (!storedCredentialId || !recoveredCredentialId || storedCredentialId !== recoveredCredentialId) { + throw new FeePayerConflictError() + } + const keypair = Keypair.fromSecret(existing) + cached = keypair + return keypair + } + resetFeePayer() + } + + const credentialId = recoveredCredentialId ?? walletLocal.getItem(KEY_ID) + const pinned = getFeePayerMode() + let mode: FeePayerMode + let keypair: Keypair + + if (recoveredKeypair) { + mode = 'legacy' + keypair = recoveredKeypair + } else if (pinned === 'prf-raw' && prf && prf.length >= 32) { + mode = 'prf-raw' + keypair = Keypair.fromRawEd25519Seed(Buffer.from(prf.subarray(0, 32))) + } else if (pinned === 'prf-hkdf' && prf && prf.length >= 32) { + mode = 'prf-hkdf' + const seed = await deriveFeePayerSeedFromPrf(prf) + keypair = Keypair.fromRawEd25519Seed(Buffer.from(seed)) + } else if (pinned === 'legacy' && credentialId) { + mode = 'legacy' + keypair = await deriveFeePayerKeypair(credentialId) + } else if (!pinned && prf && prf.length >= 32) { + mode = 'prf-raw' + keypair = Keypair.fromRawEd25519Seed(Buffer.from(prf.subarray(0, 32))) + } else if (!pinned) { + mode = 'legacy' + keypair = Keypair.random() + } else { + throw new Error('The existing fee-payer cannot be re-established without its original passkey secret.') + } + + cached = keypair + cachedDiagnostics = { + at: new Date().toISOString(), + prfAttempted: !!prf, + prfOutcome: prf ? 'success' : null, + probed: false, + candidates: [{ mode, publicKey: keypair.publicKey(), status: 'not-probed' }], + chosenMode: mode, + chosenPublicKey: keypair.publicKey(), + } + localStorage.setItem(MODE, mode) + walletSession.setItem(SECRET, keypair.secret()) + walletSession.setItem(PUBKEY, keypair.publicKey()) + walletLocal.setItem(PUBKEY, keypair.publicKey()) + if (mode === 'legacy') walletLocal.setItem(SECRET, keypair.secret()) + setDiagnostics(cachedDiagnostics) + return keypair +} + /** Cache + persist a diagnostics record (sessionStorage — metadata only, no secret). */ function setDiagnostics(diagnostics: FeePayerDiagnostics): void { cachedDiagnostics = diagnostics diff --git a/frontend/wallet/tsconfig.json b/frontend/wallet/tsconfig.json index c03efeb5..0e19dc5f 100644 --- a/frontend/wallet/tsconfig.json +++ b/frontend/wallet/tsconfig.json @@ -33,6 +33,9 @@ "@veil/sdk": [ "../../sdk/src/index" ], + "@veil/sdk/recovery/signerVerification": [ + "../../sdk/src/recovery/signerVerification" + ], "@veil/agent-core": [ "../../packages/agent/src/handler" ], diff --git a/sdk/package.json b/sdk/package.json index 8956ed94..cf9983dd 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -17,6 +17,11 @@ "react-native": "./src/vanilla.ts", "default": "./dist/vanilla.js" }, + "./recovery/signerVerification": { + "types": "./dist/recovery/signerVerification.d.ts", + "react-native": "./src/recovery/signerVerification.ts", + "default": "./dist/recovery/signerVerification.js" + }, "./react": { "types": "./dist/react/index.d.ts", "default": "./dist/react/index.js" diff --git a/sdk/src/__tests__/__snapshots__/api-surface.test.ts.snap b/sdk/src/__tests__/__snapshots__/api-surface.test.ts.snap index 82cfb34d..05eb817a 100644 --- a/sdk/src/__tests__/__snapshots__/api-surface.test.ts.snap +++ b/sdk/src/__tests__/__snapshots__/api-surface.test.ts.snap @@ -9,11 +9,14 @@ exports[`SDK API Surface should match the snapshot for main SDK exports 1`] = ` "BackupTamperError: function(arity: 0)", "FALLBACK_KEY_STORAGE: string", "FEE_PAYER_PRF_SALT: object", + "InvalidWalletAddressError: function(arity: 0)", "MAINNET_KEY_SUFFIX: string", "MemoryBackupBackend: function(arity: 0)", "NETWORKS: object", "NETWORK_STORAGE_KEY: string", "NoGuardianSet: function(arity: 0)", + "NotVeilWalletError: function(arity: 0)", + "PasskeyNotRegisteredError: function(arity: 0)", "RecoveryNotPending: function(arity: 0)", "RecoveryTimelockActive: function(arity: 1)", "Sep30Client: function(arity: 1)", @@ -26,6 +29,8 @@ exports[`SDK API Surface should match the snapshot for main SDK exports 1`] = ` "TransactionOutbox: function(arity: 2)", "WALLET_KEYS: object", "WALLET_KEY_SET: object", + "WalletContractNotFoundError: function(arity: 1)", + "WalletRecoveryNetworkError: function(arity: 2)", "assertNoSecretMaterial: function(arity: 1)", "base64UrlEncode: function(arity: 1)", "bindNewSigner: function(arity: 2)", @@ -81,6 +86,8 @@ exports[`SDK API Surface should match the snapshot for main SDK exports 1`] = ` "parseSep7PayUri: function(arity: 1)", "parseSep7QrValue: function(arity: 1)", "reclaimEscrow: function(arity: 1)", + "recoverWalletByAddress: function(arity: 2)", + "resolveWalletSigners: function(arity: 3)", "restoreBackup: function(arity: 3)", "saveBatchState: function(arity: 1)", "serializeBackup: function(arity: 1)", diff --git a/sdk/src/__tests__/signerVerification.test.ts b/sdk/src/__tests__/signerVerification.test.ts new file mode 100644 index 00000000..f83d1c2c --- /dev/null +++ b/sdk/src/__tests__/signerVerification.test.ts @@ -0,0 +1,29 @@ +import { recoverWalletByAddress, WalletContractNotFoundError } from '../recovery/signerVerification'; +import { ADDRESS_RECOVERY_CASES } from '../../tests/fixtures/addressRecoveryCases'; + +const REGISTERED = '04' + '11'.repeat(64); +const UNREGISTERED = '04' + '22'.repeat(64); + +describe('shared address recovery sequence', () => { + it.each(ADDRESS_RECOVERY_CASES)('$name', async (testCase) => { + const resolveSigners = jest.fn(async () => { + if (testCase.resolution === 'not-found') { + throw new WalletContractNotFoundError(testCase.address); + } + if (testCase.resolution === 'network-error') throw new Error('RPC unavailable'); + return testCase.resolution === 'empty' ? [] : [REGISTERED]; + }); + const authenticate = jest.fn(async () => testCase.authentication === 'registered' ? REGISTERED : UNREGISTERED); + + if (testCase.expected === 'accepted') { + await expect(recoverWalletByAddress(testCase.address, { resolveSigners, authenticate })).resolves.toMatchObject({ + address: testCase.address, + publicKey: REGISTERED, + }); + } else { + await expect(recoverWalletByAddress(testCase.address, { resolveSigners, authenticate })) + .rejects.toMatchObject({ name: testCase.expected }); + } + expect(resolveSigners).toHaveBeenCalledTimes(testCase.expected === 'InvalidWalletAddressError' ? 0 : 1); + }); +}); \ No newline at end of file diff --git a/sdk/src/index.ts b/sdk/src/index.ts index db0d7058..563aca53 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -53,6 +53,16 @@ export type { } from './sep8'; export * from './webauthn/attestation'; export * from './recovery/sep30'; +export { + recoverWalletByAddress, + resolveWalletSigners, + InvalidWalletAddressError, + WalletContractNotFoundError, + WalletRecoveryNetworkError, + PasskeyNotRegisteredError, + NotVeilWalletError, +} from './recovery/signerVerification'; +export type { AddressRecoveryDependencies, RegisteredSigner, WebAuthnAssertion } from './recovery/signerVerification'; export * from './crypto/prf'; export * from './signMessage'; export * from './bulkPayout'; diff --git a/sdk/src/recovery/signerErrors.ts b/sdk/src/recovery/signerErrors.ts new file mode 100644 index 00000000..5a7482b0 --- /dev/null +++ b/sdk/src/recovery/signerErrors.ts @@ -0,0 +1,40 @@ +export class InvalidWalletAddressError extends Error { + constructor() { + super('Enter a valid Stellar contract wallet address.'); + this.name = 'InvalidWalletAddressError'; + } +} + +export class WalletContractNotFoundError extends Error { + readonly contractAddress: string; + + constructor(readonly address: string) { + super('This wallet is not deployed on the selected network.'); + this.name = 'WalletContractNotFoundError'; + this.contractAddress = address; + } +} + +export class WalletRecoveryNetworkError extends Error { + readonly cause?: unknown; + + constructor(readonly address: string, cause?: unknown) { + super('Could not reach the selected network. Check your connection and try again.'); + this.name = 'WalletRecoveryNetworkError'; + this.cause = cause; + } +} + +export class PasskeyNotRegisteredError extends Error { + constructor() { + super('This passkey is not a registered signer on that wallet.'); + this.name = 'PasskeyNotRegisteredError'; + } +} + +export class NotVeilWalletError extends Error { + constructor() { + super('This deployed contract is not a Veil wallet or has no registered signers.'); + this.name = 'NotVeilWalletError'; + } +} \ No newline at end of file diff --git a/sdk/src/recovery/signerRegistry.ts b/sdk/src/recovery/signerRegistry.ts new file mode 100644 index 00000000..def5430c --- /dev/null +++ b/sdk/src/recovery/signerRegistry.ts @@ -0,0 +1,17 @@ +export type RegisteredSigner = Uint8Array | string + +function signerBytes(value: RegisteredSigner): Uint8Array { + if (value instanceof Uint8Array) return value + if (value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) throw new Error('Invalid signer hex') + return new Uint8Array(value.match(/.{2}/g)?.map((byte) => parseInt(byte, 16)) ?? []) +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +/** Compare signer keys by their bytes without loading Stellar SDK modules. */ +export function isRegisteredSigner(signers: RegisteredSigner[], publicKey: RegisteredSigner): boolean { + const target = toHex(signerBytes(publicKey)).toLowerCase() + return signers.some((signer) => toHex(signerBytes(signer)).toLowerCase() === target) +} diff --git a/sdk/src/recovery/signerVerification.ts b/sdk/src/recovery/signerVerification.ts new file mode 100644 index 00000000..03247276 --- /dev/null +++ b/sdk/src/recovery/signerVerification.ts @@ -0,0 +1,153 @@ +import { + Account, + BASE_FEE, + Contract, + Keypair, + StrKey, + TransactionBuilder, + rpc as SorobanRpc, +} from '@stellar/stellar-sdk'; +import { derToRawSignature, bufferToHex, hexToUint8Array } from '../utils'; +import { isRegisteredSigner, type RegisteredSigner } from './signerRegistry'; +import { + InvalidWalletAddressError, + NotVeilWalletError, + PasskeyNotRegisteredError, + WalletContractNotFoundError, + WalletRecoveryNetworkError, +} from './signerErrors'; +export { isRegisteredSigner } from './signerRegistry'; +export type { RegisteredSigner } from './signerRegistry'; +export { + InvalidWalletAddressError, + NotVeilWalletError, + PasskeyNotRegisteredError, + WalletContractNotFoundError, + WalletRecoveryNetworkError, +} from './signerErrors'; + +export type WebAuthnAssertion = { + authenticatorData: ArrayBuffer | Uint8Array; + clientDataJSON: ArrayBuffer | Uint8Array; + signature: ArrayBuffer | Uint8Array; +}; + +/** Verify one WebAuthn assertion against the wallet's on-chain signer set. */ +export async function matchWebAuthnSigner( + signers: RegisteredSigner[], + assertion: WebAuthnAssertion, +): Promise { + const toBytes = (value: ArrayBuffer | Uint8Array) => + value instanceof Uint8Array ? value : new Uint8Array(value); + const authData = toBytes(assertion.authenticatorData); + const clientDataJSON = toBytes(assertion.clientDataJSON); + const signature = toBytes(assertion.signature); + const clientDataHash = new Uint8Array( + await crypto.subtle.digest( + 'SHA-256', + clientDataJSON.buffer.slice( + clientDataJSON.byteOffset, + clientDataJSON.byteOffset + clientDataJSON.byteLength, + ) as ArrayBuffer, + ), + ); + const message = new Uint8Array(authData.length + clientDataHash.length); + message.set(authData); + message.set(clientDataHash, authData.length); + const rawSignature = derToRawSignature( + signature.buffer.slice(signature.byteOffset, signature.byteOffset + signature.byteLength) as ArrayBuffer, + ); + + for (const signer of signers) { + const publicKey = signer instanceof Uint8Array ? signer : hexToUint8Array(signer); + try { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + publicKey.buffer.slice(publicKey.byteOffset, publicKey.byteOffset + publicKey.byteLength) as ArrayBuffer, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'], + ); + const valid = await crypto.subtle.verify( + { name: 'ECDSA', hash: { name: 'SHA-256' } }, + cryptoKey, + rawSignature.buffer as ArrayBuffer, + message.buffer as ArrayBuffer, + ); + if (valid) return bufferToHex(publicKey); + } catch { + // Ignore malformed or incompatible signer entries and try the next one. + } + } + return null; +} + +export type AddressRecoveryDependencies = { + resolveSigners?: (address: string) => Promise; + rpcUrl?: string; + networkPassphrase?: string; + authenticate: (signers: RegisteredSigner[]) => Promise; +}; + +/** Simulate get_signers and parse its XDR map without relying on SDK hook state. */ +export async function resolveWalletSigners( + address: string, + rpcUrl: string, + networkPassphrase: string, +): Promise { + const source = new Account(Keypair.random().publicKey(), '0'); + const transaction = new TransactionBuilder(source, { fee: BASE_FEE, networkPassphrase }) + .addOperation(new Contract(address).call('get_signers')) + .setTimeout(30) + .build(); + const simulation = await new SorobanRpc.Server(rpcUrl).simulateTransaction(transaction); + if (SorobanRpc.Api.isSimulationError(simulation)) { + if (/not found|not_found|contract instance|missingvalue|missing value/i.test(simulation.error)) { + throw new WalletContractNotFoundError(address); + } + if (/network|fetch|timeout|timed out|connection|\b429\b|\b5\d\d\b|gateway|temporarily unavailable/i.test(simulation.error)) { + throw new WalletRecoveryNetworkError(address); + } + throw new NotVeilWalletError(); + } + + const retval = simulation.result?.retval; + if (!retval) throw new NotVeilWalletError(); + try { + return retval.map()?.map((entry) => new Uint8Array(entry.val().bytes())) ?? []; + } catch { + throw new NotVeilWalletError(); + } +} + +/** Validate, resolve, and verify an address using one rule on every platform. */ +export async function recoverWalletByAddress( + input: string, + dependencies: AddressRecoveryDependencies, +): Promise<{ address: string; signers: RegisteredSigner[]; publicKey: string }> { + const address = input.trim(); + if (!StrKey.isValidContract(address)) throw new InvalidWalletAddressError(); + + let signers: RegisteredSigner[]; + try { + if (dependencies.resolveSigners) { + signers = await dependencies.resolveSigners(address); + } else if (dependencies.rpcUrl && dependencies.networkPassphrase) { + signers = await resolveWalletSigners(address, dependencies.rpcUrl, dependencies.networkPassphrase); + } else { + throw new Error('Address recovery requires a signer resolver or network configuration.'); + } + } catch (error) { + if ( + error instanceof WalletContractNotFoundError || + error instanceof WalletRecoveryNetworkError || + error instanceof NotVeilWalletError + ) throw error; + throw new WalletRecoveryNetworkError(address, error); + } + if (signers.length === 0) throw new NotVeilWalletError(); + + const publicKey = await dependencies.authenticate(signers); + if (!publicKey || !isRegisteredSigner(signers, publicKey)) throw new PasskeyNotRegisteredError(); + return { address, signers, publicKey }; +} diff --git a/sdk/tests/fixtures/addressRecoveryCases.ts b/sdk/tests/fixtures/addressRecoveryCases.ts new file mode 100644 index 00000000..66cebe68 --- /dev/null +++ b/sdk/tests/fixtures/addressRecoveryCases.ts @@ -0,0 +1,9 @@ +export const ADDRESS_RECOVERY_CASES = [ + { name: 'valid registered signer', address: 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE', resolution: 'signers', authentication: 'registered', expected: 'accepted' }, + { name: 'malformed address', address: 'C123', resolution: 'signers', authentication: 'registered', expected: 'InvalidWalletAddressError' }, + { name: 'bad StrKey checksum', address: `C${'B'.repeat(55)}`, resolution: 'signers', authentication: 'registered', expected: 'InvalidWalletAddressError' }, + { name: 'undeployed wallet', address: 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE', resolution: 'not-found', authentication: 'registered', expected: 'WalletContractNotFoundError' }, + { name: 'unreachable network', address: 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE', resolution: 'network-error', authentication: 'registered', expected: 'WalletRecoveryNetworkError' }, + { name: 'passkey is not registered', address: 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE', resolution: 'signers', authentication: 'unregistered', expected: 'PasskeyNotRegisteredError' }, + { name: 'deployed non-wallet contract', address: 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE', resolution: 'empty', authentication: 'registered', expected: 'NotVeilWalletError' }, +] as const; \ No newline at end of file