diff --git a/centrifuge-app/src/components/OnboardingAuthProvider.tsx b/centrifuge-app/src/components/OnboardingAuthProvider.tsx index 53cfb23100..307ddb15fc 100644 --- a/centrifuge-app/src/components/OnboardingAuthProvider.tsx +++ b/centrifuge-app/src/components/OnboardingAuthProvider.tsx @@ -2,6 +2,8 @@ import Centrifuge from '@centrifuge/centrifuge-js' import { useCentrifuge, useCentrifugeUtils, useEvmProvider, useWallet } from '@centrifuge/centrifuge-react' import { encodeAddress } from '@polkadot/util-crypto' import { Wallet } from '@subwallet/wallet-connect/types' +import { BigNumber, ethers } from 'ethers' +import { hashMessage } from 'ethers/lib/utils' import * as React from 'react' import { useMutation, useQuery } from 'react-query' @@ -222,20 +224,43 @@ Nonce: ${nonce} Issued At: ${new Date().toISOString()}` const signedMessage = await signer?.signMessage(message) - const tokenRes = await fetch(`${import.meta.env.REACT_APP_ONBOARDING_API_URL}/authenticateWallet`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ + + let body + + if (signedMessage === '0x') { + const messageHash = hashMessage(message) + + const isValid = await isValidSignature(signer, address, messageHash, evmChainId || 1) + + if (isValid) { + body = JSON.stringify({ + safeAddress: address, + messageHash, + evmChainId, + network: 'evmOnSafe', + nonce, + }) + } else { + throw new Error('Invalid signature') + } + } else { + body = JSON.stringify({ message, signature: signedMessage, address, nonce, network: isEvmOnSubstrate ? 'evmOnSubstrate' : 'evm', chainId: evmChainId || 1, - }), + }) + } + + const tokenRes = await fetch(`${import.meta.env.REACT_APP_ONBOARDING_API_URL}/authenticateWallet`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body, }) if (tokenRes.status !== 200) { throw new Error('Failed to authenticate wallet') @@ -249,3 +274,50 @@ Issued At: ${new Date().toISOString()}` ) } } + +const isValidSignature = async (provider: any, safeAddress: string, messageHash: string, evmChainId: number) => { + const MAGIC_VALUE_BYTES = '0x20c13b0b' + + const safeContract = new ethers.Contract( + safeAddress, + [ + 'function isValidSignature(bytes calldata _data, bytes calldata _signature) public view returns (bytes4)', + 'function getMessageHash(bytes memory message) public view returns (bytes32)', + 'function getThreshold() public view returns (uint256)', + ], + provider + ) + + const safeMessageHash = await safeContract.getMessageHash(messageHash) + + const safeMessage = await fetchSafeMessage(safeMessageHash, evmChainId) + + if (!safeMessage) { + throw new Error('Unable to fetch SafeMessage') + } + + const threshold = BigNumber.from(await safeContract.getThreshold()).toNumber() + + if (!threshold || threshold > safeMessage.confirmations.length) { + throw new Error('Threshold has not been met') + } + + const response = await safeContract.isValidSignature(messageHash, safeMessage?.preparedSignature) + + return response === MAGIC_VALUE_BYTES +} + +const TX_SERVICE_URLS: Record = { + '1': 'https://safe-transaction-mainnet.safe.global/api', + '5': 'https://safe-transaction-goerli.safe.global/api', +} + +const fetchSafeMessage = async (safeMessageHash: string, chainId: number) => { + const TX_SERVICE_URL = TX_SERVICE_URLS[chainId.toString()] + + const response = await fetch(`${TX_SERVICE_URL}/v1/messages/${safeMessageHash}/`, { + headers: { 'Content-Type': 'application/json' }, + }) + + return response.json() +} diff --git a/onboarding-api/src/controllers/auth/authenticateWallet.ts b/onboarding-api/src/controllers/auth/authenticateWallet.ts index 34ec8c1169..79bd3811f1 100644 --- a/onboarding-api/src/controllers/auth/authenticateWallet.ts +++ b/onboarding-api/src/controllers/auth/authenticateWallet.ts @@ -33,13 +33,30 @@ const verifyWalletInput = object({ chainId: number().required(), }) +const verifySafeInput = object({ + messageHash: string().required(), + safeAddress: string() + .required() + .test({ + name: 'is-address', + test(value, ctx) { + if (isAddress(value)) return true + return ctx.createError({ message: 'Invalid address', path: ctx.path }) + }, + }), + evmChainId: number().required(), + network: string().oneOf(['evmOnSafe']).required() as StringSchema<'evmOnSafe'>, + nonce: string().required(), +}) + export const authenticateWalletController = async ( - req: Request<{}, {}, InferType>, + req: Request<{}, {}, InferType>, res: Response ) => { try { - await validateInput(req.body, verifyWalletInput) - const payload = await new NetworkSwitch(req.body.network).verifiyWallet(req, res) + await validateInput(req.body, req.body.network === 'evmOnSafe' ? verifySafeInput : verifyWalletInput) + + const payload = await new NetworkSwitch(req.body.network).verifyWallet(req, res) const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '8h', diff --git a/onboarding-api/src/utils/networks/evm.ts b/onboarding-api/src/utils/networks/evm.ts index 7463fc7b60..02dc376266 100644 --- a/onboarding-api/src/utils/networks/evm.ts +++ b/onboarding-api/src/utils/networks/evm.ts @@ -1,7 +1,9 @@ import { isAddress } from '@ethersproject/address' import { Contract } from '@ethersproject/contracts' import { InfuraProvider, JsonRpcProvider, Provider } from '@ethersproject/providers' +import { BigNumber, ethers } from 'ethers' import { Request, Response } from 'express' +import fetch from 'node-fetch' import { SiweMessage } from 'siwe' import { InferType } from 'yup' import { signAndSendDocumentsInput } from '../../controllers/emails/signAndSendDocuments' @@ -66,3 +68,71 @@ export async function verifyEvmWallet(req: Request, res: Response): Promise { + const { safeAddress, messageHash, evmChainId, nonce } = req.body + const MAGIC_VALUE_BYTES = '0x20c13b0b' + + if (!isAddress(safeAddress)) { + throw new HttpError(400, 'Invalid address') + } + + const cookieNonce = req.signedCookies[`onboarding-auth-${safeAddress.toLowerCase()}`] + + if (!cookieNonce || cookieNonce !== nonce) { + throw new HttpError(400, 'Invalid nonce') + } + + const provider = new InfuraProvider(req.body.evmChainId, process.env.INFURA_KEY) + const safeContract = new ethers.Contract( + safeAddress, + [ + 'function isValidSignature(bytes calldata _data, bytes calldata _signature) public view returns (bytes4)', + 'function getMessageHash(bytes memory message) public view returns (bytes32)', + 'function getThreshold() public view returns (uint256)', + ], + provider + ) + + const safeMessageHash = await safeContract.getMessageHash(messageHash) + + const safeMessage = await fetchSafeMessage(safeMessageHash, evmChainId) + + if (!safeMessage) { + throw new HttpError(400, 'Unable to fetch SafeMessage') + } + + const threshold = BigNumber.from(await safeContract.getThreshold()).toNumber() + + if (!threshold || threshold > safeMessage.confirmations.length) { + throw new HttpError(400, 'Threshold has not been met') + } + + const response = await safeContract.isValidSignature(messageHash, safeMessage?.preparedSignature) + res.clearCookie(`onboarding-auth-${safeAddress.toLowerCase()}`) + + if (response === MAGIC_VALUE_BYTES) { + return { + address: safeAddress, + chainId: evmChainId, + network: 'evm', + } + } + + throw new HttpError(400, 'Invalid signature') +} + +const TX_SERVICE_URLS: Record = { + '1': 'https://safe-transaction-mainnet.safe.global/api', + '5': 'https://safe-transaction-goerli.safe.global/api', +} + +const fetchSafeMessage = async (safeMessageHash: string, chainId: number) => { + const TX_SERVICE_URL = TX_SERVICE_URLS[chainId.toString()] + + const response = await fetch(`${TX_SERVICE_URL}/v1/messages/${safeMessageHash}/`, { + headers: { 'Content-Type': 'application/json' }, + }) + + return response.json() +} diff --git a/onboarding-api/src/utils/networks/networkSwitch.ts b/onboarding-api/src/utils/networks/networkSwitch.ts index 73a9d14976..334d9f2362 100644 --- a/onboarding-api/src/utils/networks/networkSwitch.ts +++ b/onboarding-api/src/utils/networks/networkSwitch.ts @@ -9,18 +9,20 @@ import { validateSubstrateRemark, verifySubstrateWallet, } from './centrifuge' -import { validateEvmRemark, verifyEvmWallet } from './evm' +import { validateEvmRemark, verifyEvmWallet, verifySafeWallet } from './evm' import { addTinlakeInvestorToMemberList, getTinlakePoolById } from './tinlake' export class NetworkSwitch { - network: SupportedNetworks - constructor(network: SupportedNetworks = 'substrate') { + network: SupportedNetworks | 'evmOnSafe' + constructor(network: SupportedNetworks | 'evmOnSafe' = 'substrate') { this.network = network } - verifiyWallet = (req: Request, res: Response) => { + verifyWallet = (req: Request, res: Response) => { if (this.network === 'substrate') { return verifySubstrateWallet(req, res) + } else if (this.network === 'evmOnSafe') { + return verifySafeWallet(req, res) } else if (this.network === 'evm' || this.network === 'evmOnSubstrate') { return verifyEvmWallet(req, res) }