Skip to content

Commit

Permalink
Merge pull request #331 from pendulum-chain/326-fix-wrong-use-of-evm-…
Browse files Browse the repository at this point in the history
…account-address-for-assethub-offramp-b

Add SIWE signing support for Substrate wallets.
  • Loading branch information
gianfra-t authored Dec 19, 2024
2 parents fd5a5d9 + 5e6a693 commit d632cb4
Show file tree
Hide file tree
Showing 18 changed files with 350 additions and 207 deletions.
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
"bn.js": "^5.2.1",
"buffer": "^6.0.3",
"daisyui": "^4.11.1",
"ethers": "^6.13.4",
"framer-motion": "^11.2.14",
"postcss": "^8.4.38",
"preact": "^10.12.1",
Expand All @@ -57,7 +56,6 @@
"react-hook-form": "^7.51.5",
"react-router-dom": "^6.8.1",
"react-toastify": "^10.0.6",
"siwe": "^2.3.2",
"stellar-base": "^11.0.1",
"stellar-sdk": "^11.3.0",
"tailwind": "^4.0.0",
Expand All @@ -80,7 +78,7 @@
"@preact/preset-vite": "^2.9.1",
"@types/big.js": "^6",
"@types/bn.js": "^5",
"@types/node": "^18.14.1",
"@types/node": "^22.7.5",
"@types/react": "^18.3.10",
"@typescript-eslint/eslint-plugin": "^5.53.0",
"@typescript-eslint/parser": "^5.53.0",
Expand Down
4 changes: 2 additions & 2 deletions signer-service/src/api/controllers/siwe.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ exports.sendSiweMessage = async (req, res) => {
exports.validateSiweSignature = async (req, res) => {
const { nonce, signature, siweMessage } = req.body;
try {
await verifyAndStoreSiweMessage(nonce, signature, siweMessage);
const address = await verifyAndStoreSiweMessage(nonce, signature, siweMessage);

const token = {
nonce,
signature,
};

res.cookie('authToken', token, {
res.cookie(`authToken_${address}`, token, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
Expand Down
24 changes: 1 addition & 23 deletions signer-service/src/api/controllers/stellar.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,36 +56,14 @@ exports.changeOpTransaction = async (req, res, next) => {

exports.signSep10Challenge = async (req, res, next) => {
try {
let maybeChallengeSignature;
let maybeNonce;
if (req.cookies?.authToken) {
maybeChallengeSignature = req.cookies.authToken.signature;
maybeNonce = req.cookies.authToken.nonce;
}

if (Boolean(req.body.memo) && (!maybeChallengeSignature || !maybeNonce)) {
return res.status(401).json({
error: 'Missing signature or nonce',
});
}

let { masterClientSignature, masterClientPublic, clientSignature, clientPublic } = await signSep10Challenge(
req.body.challengeXDR,
req.body.outToken,
req.body.clientPublicKey,
maybeChallengeSignature,
maybeNonce,
req.derivedMemo, // Derived in middleware
);
return res.json({ masterClientSignature, masterClientPublic, clientSignature, clientPublic });
} catch (error) {
if (error.message.includes('Could not verify signature')) {
// Distinguish between failed signature check and other errors.
return res.status(401).json({
error: 'Signature validation failed.',
details: error.message,
});
}

console.error('Error in signSep10Challenge:', error);
return res.status(500).json({ error: 'Failed to sign challenge', details: error.message });
}
Expand Down
21 changes: 21 additions & 0 deletions signer-service/src/api/helpers/memoDerivation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const { keccak256 } = require('viem/utils');
const { Keyring } = require('@polkadot/api');

// Returns the hash value for the address. If it's a polkadot address, it will return raw data of the address.
function getHashValueForAddress(address) {
if (address.startsWith('0x')) {
return address;
} else {
const keyring = new Keyring({ type: 'sr25519' });
return keyring.decodeAddress(address);
}
}

//A memo derivation.
async function deriveMemoFromAddress(address) {
const hashValue = getHashValueForAddress(address);
const hash = keccak256(hashValue);
return BigInt(hash).toString().slice(0, 15);
}

module.exports = { deriveMemoFromAddress };
53 changes: 53 additions & 0 deletions signer-service/src/api/helpers/siweMessageFormatter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
class SignInMessage {
// fixed statement string
static LOGIN_MESSAGE = ' wants you to sign in with your account: ';

constructor(fields) {
this.scheme = fields.scheme;
this.domain = fields.domain;
this.address = fields.address;
this.nonce = fields.nonce;
this.expirationTime = new Date(fields.expirationTime).toISOString();
this.issuedAt = fields.issuedAt ? new Date(fields.issuedAt).toISOString() : new Date().toISOString();
}

toMessage() {
const header = `${this.domain}${SignInMessage.LOGIN_MESSAGE}${this.address}`;

const body = `\nNonce: ${this.nonce}\nIssued At: ${this.issuedAt}\nExpiration Time: ${this.expirationTime}`;

return `${header}\n\n${body}`;
}

static fromMessage(message) {
const lines = message
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);

const headerLine = lines.find((line) => line.includes(SignInMessage.LOGIN_MESSAGE)) || '';
const [domain, address] = headerLine.split(SignInMessage.LOGIN_MESSAGE).map((part) => part.trim());

const nonceLine = lines.find((line) => line.startsWith('Nonce:')) || '';
const nonce = nonceLine.split('Nonce:')[1]?.trim() || '';

const issuedAtLine = lines.find((line) => line.startsWith('Issued At:')) || '';
const issuedAt = issuedAtLine.split('Issued At:')[1]?.trim(); // Can't really be empty. Constructor will default to current date if not defined.
const issuedAtMilis = new Date(issuedAt).getTime();

const expirationTimeLine = lines.find((line) => line.startsWith('Expiration Time:')) || '';
const expirationTime = expirationTimeLine.split('Expiration Time:')[1]?.trim();
const expirationTimeMilis = new Date(expirationTime).getTime();

return new SignInMessage({
scheme: 'https',
domain,
address,
nonce,
expirationTime: expirationTimeMilis,
issuedAt: issuedAtMilis,
});
}
}

module.exports = { SignInMessage };
77 changes: 77 additions & 0 deletions signer-service/src/api/middlewares/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const { validateSignatureAndGetMemo } = require('../services/siwe.service');

const getMemoFromCookiesMiddleware = async (req, res, next) => {
// If the client didn't specify, we don't want to pass a derived memo even if a cookie was sent.

req.derivedMemo = null; // Explicit overwrite to avoid tampering, defensive.
if (!Boolean(req.body.usesMemo)) {
return next();
}
try {
const cookies = req.cookies;
const address = req.body.address;
// Default memo (represents no memo usage at all)
let resultMemo = null;

for (const authToken in cookies) {
if (!authToken.startsWith('authToken_')) {
continue;
}

//check if matches the address requested by client, otherwise ignore cookie.
if (!authToken.includes(address)) {
continue;
}

try {
const token = cookies[authToken];
const signature = token.signature;
const nonce = token.nonce;

if (!signature || !nonce) {
continue;
}

const memo = await validateSignatureAndGetMemo(nonce, signature);
console.log(memo);

// First found first used
if (memo) {
resultMemo = memo;
break;
}
} catch (e) {
continue;
}
}

// Client declared usage of memo, but it could not be derived from provided signatures.
if (Boolean(req.body.usesMemo) && !resultMemo) {
return res.status(401).json({
error: 'Missing or invalid authentication token',
});
}

req.derivedMemo = resultMemo;

next();
} catch (err) {
if (err.message.includes('Could not verify signature')) {
// Distinguish between failed signature check and other errors.
return res.status(401).json({
error: 'Signature validation failed.',
details: err.message,
});
}
console.error(`Error in getMemoFromCookiesMiddleware: ${err.message}`);

return res.status(500).json({
error: 'Error while verifying signature',
details: err.message,
});
}
};

module.exports = {
getMemoFromCookiesMiddleware,
};
4 changes: 3 additions & 1 deletion signer-service/src/api/routes/v1/stellar.route.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
const express = require('express');
const controller = require('../../controllers/stellar.controller');
const { validateCreationInput, validateChangeOpInput, validateSep10Input } = require('../../middlewares/validators');
const { getMemoFromCookiesMiddleware } = require('../../middlewares/auth');

const router = express.Router({ mergeParams: true });

router.route('/create').post(validateCreationInput, controller.createStellarTransaction);

router.route('/payment').post(validateChangeOpInput, controller.changeOpTransaction);

router.route('/sep10').post(validateSep10Input, controller.signSep10Challenge);
// Only authorized route. Does not reject the request, but rather passes the memo (if any) derived from a valid cookie in the request.
router.route('/sep10').post([validateSep10Input, getMemoFromCookiesMiddleware], controller.signSep10Challenge);

router.route('/sep10').get(controller.getSep10MasterPK);

Expand Down
32 changes: 1 addition & 31 deletions signer-service/src/api/services/sep10.service.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,19 @@
const { Keypair } = require('stellar-sdk');
const { TransactionBuilder, Networks } = require('stellar-sdk');
const { fetchTomlValues } = require('../helpers/anchors');
const { verifySiweMessage } = require('./siwe.service');
const { keccak256 } = require('viem/utils');

const { TOKEN_CONFIG } = require('../../constants/tokenConfig');
const { SEP10_MASTER_SECRET, CLIENT_DOMAIN_SECRET } = require('../../constants/constants');

const NETWORK_PASSPHRASE = Networks.PUBLIC;

async function deriveMemoFromAddress(address) {
const hash = keccak256(address);
return BigInt(hash).toString().slice(0, 15);
}

// we validate a challenge for a given nonce. From it we obtain the address and derive the memo
// we can then ensure that the memo is the same as the one we expect from the anchor challenge
const validateSignatureAndGetMemo = async (nonce, userChallengeSignature, memoEnabled) => {
if (!userChallengeSignature || !nonce || !memoEnabled) {
return null; // Default memo value when single stellar account is used
}

let message;
try {
// initialSiweMessage must be undefined after an initial check,
// message must exist on the map.
message = await verifySiweMessage(nonce, userChallengeSignature, undefined);
} catch (e) {
throw new Error(`Could not verify signature: ${e.message}`);
}

const memo = await deriveMemoFromAddress(message.address);
return memo;
};

exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, userChallengeSignature, nonce) => {
exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, memo) => {
const masterStellarKeypair = Keypair.fromSecret(SEP10_MASTER_SECRET);
const clientDomainStellarKeypair = Keypair.fromSecret(CLIENT_DOMAIN_SECRET);

const { signingKey: anchorSigningKey } = await fetchTomlValues(TOKEN_CONFIG[outToken].tomlFileUrl);
const { homeDomain, clientDomainEnabled, memoEnabled } = TOKEN_CONFIG[outToken];

// Expected memo based on user's signature and nonce.
const memo = await validateSignatureAndGetMemo(nonce, userChallengeSignature, memoEnabled);

const transactionSigned = new TransactionBuilder.fromXDR(challengeXDR, NETWORK_PASSPHRASE);
if (transactionSigned.source !== anchorSigningKey) {
throw new Error(`Invalid source account: ${transactionSigned.source}`);
Expand Down
Loading

0 comments on commit d632cb4

Please sign in to comment.