Skip to content

Commit d632cb4

Browse files
authored
Merge pull request #331 from pendulum-chain/326-fix-wrong-use-of-evm-account-address-for-assethub-offramp-b
Add SIWE signing support for Substrate wallets.
2 parents fd5a5d9 + 5e6a693 commit d632cb4

File tree

18 files changed

+350
-207
lines changed

18 files changed

+350
-207
lines changed

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
"bn.js": "^5.2.1",
4848
"buffer": "^6.0.3",
4949
"daisyui": "^4.11.1",
50-
"ethers": "^6.13.4",
5150
"framer-motion": "^11.2.14",
5251
"postcss": "^8.4.38",
5352
"preact": "^10.12.1",
@@ -57,7 +56,6 @@
5756
"react-hook-form": "^7.51.5",
5857
"react-router-dom": "^6.8.1",
5958
"react-toastify": "^10.0.6",
60-
"siwe": "^2.3.2",
6159
"stellar-base": "^11.0.1",
6260
"stellar-sdk": "^11.3.0",
6361
"tailwind": "^4.0.0",
@@ -80,7 +78,7 @@
8078
"@preact/preset-vite": "^2.9.1",
8179
"@types/big.js": "^6",
8280
"@types/bn.js": "^5",
83-
"@types/node": "^18.14.1",
81+
"@types/node": "^22.7.5",
8482
"@types/react": "^18.3.10",
8583
"@typescript-eslint/eslint-plugin": "^5.53.0",
8684
"@typescript-eslint/parser": "^5.53.0",

signer-service/src/api/controllers/siwe.controller.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ exports.sendSiweMessage = async (req, res) => {
1717
exports.validateSiweSignature = async (req, res) => {
1818
const { nonce, signature, siweMessage } = req.body;
1919
try {
20-
await verifyAndStoreSiweMessage(nonce, signature, siweMessage);
20+
const address = await verifyAndStoreSiweMessage(nonce, signature, siweMessage);
2121

2222
const token = {
2323
nonce,
2424
signature,
2525
};
2626

27-
res.cookie('authToken', token, {
27+
res.cookie(`authToken_${address}`, token, {
2828
httpOnly: true,
2929
secure: true,
3030
sameSite: 'Strict',

signer-service/src/api/controllers/stellar.controller.js

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -56,36 +56,14 @@ exports.changeOpTransaction = async (req, res, next) => {
5656

5757
exports.signSep10Challenge = async (req, res, next) => {
5858
try {
59-
let maybeChallengeSignature;
60-
let maybeNonce;
61-
if (req.cookies?.authToken) {
62-
maybeChallengeSignature = req.cookies.authToken.signature;
63-
maybeNonce = req.cookies.authToken.nonce;
64-
}
65-
66-
if (Boolean(req.body.memo) && (!maybeChallengeSignature || !maybeNonce)) {
67-
return res.status(401).json({
68-
error: 'Missing signature or nonce',
69-
});
70-
}
71-
7259
let { masterClientSignature, masterClientPublic, clientSignature, clientPublic } = await signSep10Challenge(
7360
req.body.challengeXDR,
7461
req.body.outToken,
7562
req.body.clientPublicKey,
76-
maybeChallengeSignature,
77-
maybeNonce,
63+
req.derivedMemo, // Derived in middleware
7864
);
7965
return res.json({ masterClientSignature, masterClientPublic, clientSignature, clientPublic });
8066
} catch (error) {
81-
if (error.message.includes('Could not verify signature')) {
82-
// Distinguish between failed signature check and other errors.
83-
return res.status(401).json({
84-
error: 'Signature validation failed.',
85-
details: error.message,
86-
});
87-
}
88-
8967
console.error('Error in signSep10Challenge:', error);
9068
return res.status(500).json({ error: 'Failed to sign challenge', details: error.message });
9169
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const { keccak256 } = require('viem/utils');
2+
const { Keyring } = require('@polkadot/api');
3+
4+
// Returns the hash value for the address. If it's a polkadot address, it will return raw data of the address.
5+
function getHashValueForAddress(address) {
6+
if (address.startsWith('0x')) {
7+
return address;
8+
} else {
9+
const keyring = new Keyring({ type: 'sr25519' });
10+
return keyring.decodeAddress(address);
11+
}
12+
}
13+
14+
//A memo derivation.
15+
async function deriveMemoFromAddress(address) {
16+
const hashValue = getHashValueForAddress(address);
17+
const hash = keccak256(hashValue);
18+
return BigInt(hash).toString().slice(0, 15);
19+
}
20+
21+
module.exports = { deriveMemoFromAddress };
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
class SignInMessage {
2+
// fixed statement string
3+
static LOGIN_MESSAGE = ' wants you to sign in with your account: ';
4+
5+
constructor(fields) {
6+
this.scheme = fields.scheme;
7+
this.domain = fields.domain;
8+
this.address = fields.address;
9+
this.nonce = fields.nonce;
10+
this.expirationTime = new Date(fields.expirationTime).toISOString();
11+
this.issuedAt = fields.issuedAt ? new Date(fields.issuedAt).toISOString() : new Date().toISOString();
12+
}
13+
14+
toMessage() {
15+
const header = `${this.domain}${SignInMessage.LOGIN_MESSAGE}${this.address}`;
16+
17+
const body = `\nNonce: ${this.nonce}\nIssued At: ${this.issuedAt}\nExpiration Time: ${this.expirationTime}`;
18+
19+
return `${header}\n\n${body}`;
20+
}
21+
22+
static fromMessage(message) {
23+
const lines = message
24+
.split('\n')
25+
.map((l) => l.trim())
26+
.filter((l) => l.length > 0);
27+
28+
const headerLine = lines.find((line) => line.includes(SignInMessage.LOGIN_MESSAGE)) || '';
29+
const [domain, address] = headerLine.split(SignInMessage.LOGIN_MESSAGE).map((part) => part.trim());
30+
31+
const nonceLine = lines.find((line) => line.startsWith('Nonce:')) || '';
32+
const nonce = nonceLine.split('Nonce:')[1]?.trim() || '';
33+
34+
const issuedAtLine = lines.find((line) => line.startsWith('Issued At:')) || '';
35+
const issuedAt = issuedAtLine.split('Issued At:')[1]?.trim(); // Can't really be empty. Constructor will default to current date if not defined.
36+
const issuedAtMilis = new Date(issuedAt).getTime();
37+
38+
const expirationTimeLine = lines.find((line) => line.startsWith('Expiration Time:')) || '';
39+
const expirationTime = expirationTimeLine.split('Expiration Time:')[1]?.trim();
40+
const expirationTimeMilis = new Date(expirationTime).getTime();
41+
42+
return new SignInMessage({
43+
scheme: 'https',
44+
domain,
45+
address,
46+
nonce,
47+
expirationTime: expirationTimeMilis,
48+
issuedAt: issuedAtMilis,
49+
});
50+
}
51+
}
52+
53+
module.exports = { SignInMessage };
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const { validateSignatureAndGetMemo } = require('../services/siwe.service');
2+
3+
const getMemoFromCookiesMiddleware = async (req, res, next) => {
4+
// If the client didn't specify, we don't want to pass a derived memo even if a cookie was sent.
5+
6+
req.derivedMemo = null; // Explicit overwrite to avoid tampering, defensive.
7+
if (!Boolean(req.body.usesMemo)) {
8+
return next();
9+
}
10+
try {
11+
const cookies = req.cookies;
12+
const address = req.body.address;
13+
// Default memo (represents no memo usage at all)
14+
let resultMemo = null;
15+
16+
for (const authToken in cookies) {
17+
if (!authToken.startsWith('authToken_')) {
18+
continue;
19+
}
20+
21+
//check if matches the address requested by client, otherwise ignore cookie.
22+
if (!authToken.includes(address)) {
23+
continue;
24+
}
25+
26+
try {
27+
const token = cookies[authToken];
28+
const signature = token.signature;
29+
const nonce = token.nonce;
30+
31+
if (!signature || !nonce) {
32+
continue;
33+
}
34+
35+
const memo = await validateSignatureAndGetMemo(nonce, signature);
36+
console.log(memo);
37+
38+
// First found first used
39+
if (memo) {
40+
resultMemo = memo;
41+
break;
42+
}
43+
} catch (e) {
44+
continue;
45+
}
46+
}
47+
48+
// Client declared usage of memo, but it could not be derived from provided signatures.
49+
if (Boolean(req.body.usesMemo) && !resultMemo) {
50+
return res.status(401).json({
51+
error: 'Missing or invalid authentication token',
52+
});
53+
}
54+
55+
req.derivedMemo = resultMemo;
56+
57+
next();
58+
} catch (err) {
59+
if (err.message.includes('Could not verify signature')) {
60+
// Distinguish between failed signature check and other errors.
61+
return res.status(401).json({
62+
error: 'Signature validation failed.',
63+
details: err.message,
64+
});
65+
}
66+
console.error(`Error in getMemoFromCookiesMiddleware: ${err.message}`);
67+
68+
return res.status(500).json({
69+
error: 'Error while verifying signature',
70+
details: err.message,
71+
});
72+
}
73+
};
74+
75+
module.exports = {
76+
getMemoFromCookiesMiddleware,
77+
};

signer-service/src/api/routes/v1/stellar.route.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
const express = require('express');
22
const controller = require('../../controllers/stellar.controller');
33
const { validateCreationInput, validateChangeOpInput, validateSep10Input } = require('../../middlewares/validators');
4+
const { getMemoFromCookiesMiddleware } = require('../../middlewares/auth');
45

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

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

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

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

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

signer-service/src/api/services/sep10.service.js

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,19 @@
11
const { Keypair } = require('stellar-sdk');
22
const { TransactionBuilder, Networks } = require('stellar-sdk');
33
const { fetchTomlValues } = require('../helpers/anchors');
4-
const { verifySiweMessage } = require('./siwe.service');
5-
const { keccak256 } = require('viem/utils');
64

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

108
const NETWORK_PASSPHRASE = Networks.PUBLIC;
119

12-
async function deriveMemoFromAddress(address) {
13-
const hash = keccak256(address);
14-
return BigInt(hash).toString().slice(0, 15);
15-
}
16-
17-
// we validate a challenge for a given nonce. From it we obtain the address and derive the memo
18-
// we can then ensure that the memo is the same as the one we expect from the anchor challenge
19-
const validateSignatureAndGetMemo = async (nonce, userChallengeSignature, memoEnabled) => {
20-
if (!userChallengeSignature || !nonce || !memoEnabled) {
21-
return null; // Default memo value when single stellar account is used
22-
}
23-
24-
let message;
25-
try {
26-
// initialSiweMessage must be undefined after an initial check,
27-
// message must exist on the map.
28-
message = await verifySiweMessage(nonce, userChallengeSignature, undefined);
29-
} catch (e) {
30-
throw new Error(`Could not verify signature: ${e.message}`);
31-
}
32-
33-
const memo = await deriveMemoFromAddress(message.address);
34-
return memo;
35-
};
36-
37-
exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, userChallengeSignature, nonce) => {
10+
exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, memo) => {
3811
const masterStellarKeypair = Keypair.fromSecret(SEP10_MASTER_SECRET);
3912
const clientDomainStellarKeypair = Keypair.fromSecret(CLIENT_DOMAIN_SECRET);
4013

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

44-
// Expected memo based on user's signature and nonce.
45-
const memo = await validateSignatureAndGetMemo(nonce, userChallengeSignature, memoEnabled);
46-
4717
const transactionSigned = new TransactionBuilder.fromXDR(challengeXDR, NETWORK_PASSPHRASE);
4818
if (transactionSigned.source !== anchorSigningKey) {
4919
throw new Error(`Invalid source account: ${transactionSigned.source}`);

0 commit comments

Comments
 (0)