Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authenticate recurrent users with memo. #257

Open
wants to merge 27 commits into
base: polygon-prototype-staging
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9778b62
merged with WIP sign-client-domain
gianfra-t Nov 1, 2024
72839fb
merge with add-ars-offramp-support
gianfra-t Nov 1, 2024
3c3ac6a
WIP
gianfra-t Nov 4, 2024
32a3d5a
merged with current staging branch
gianfra-t Nov 5, 2024
db71c33
add message creation endpoint for testing
gianfra-t Nov 5, 2024
d8f2b2c
add simple solution for message signin
gianfra-t Nov 5, 2024
7d91840
merged with ars integration, client domain
gianfra-t Nov 6, 2024
d0202f2
work in progress memo solution
gianfra-t Nov 6, 2024
780b17b
remove comments
gianfra-t Nov 6, 2024
1996e64
remove and improve comments
gianfra-t Nov 6, 2024
b4eaddf
remove master account, either use memo or not
gianfra-t Nov 8, 2024
79beb77
improve signature hook
gianfra-t Nov 8, 2024
9fae39e
merge current client_domain implementation
gianfra-t Nov 11, 2024
ec8e798
use viem to validate message on backend, error handling
gianfra-t Nov 11, 2024
2b14bbe
improve sign in hooks
gianfra-t Nov 11, 2024
c3bcbd1
invalidate older sessions after log in
gianfra-t Nov 11, 2024
bf5d521
remove poll from sign in hooks
gianfra-t Nov 12, 2024
9e92784
merged:staging
gianfra-t Nov 12, 2024
a6be1ed
better memo derivation, cleaning
gianfra-t Nov 12, 2024
9f671b5
add validators
gianfra-t Nov 12, 2024
9f064b9
more cleanup, comments
gianfra-t Nov 12, 2024
4db9ef8
yet more cleanup
gianfra-t Nov 12, 2024
cd844f1
lint errors, remove useRef
gianfra-t Nov 13, 2024
2b18ed0
initialization failed state on handleOnSumbit fail
gianfra-t Nov 14, 2024
22f2339
move siwe to context
gianfra-t Nov 15, 2024
c6d74f2
cancel sep24 first on token selection change
gianfra-t Nov 15, 2024
e12b1fe
wip store signature as auth cookie
gianfra-t Nov 15, 2024
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"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 @@ -56,6 +57,7 @@
"react-hook-form": "^7.51.5",
"react-router-dom": "^6.8.1",
"react-toastify": "^10.0.5",
"siwe": "^2.3.2",
"stellar-base": "^11.0.1",
"stellar-sdk": "^11.3.0",
"tailwind": "^4.0.0",
Expand Down
2 changes: 2 additions & 0 deletions signer-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"cors": "^2.8.3",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"ethers": "^6.13.4",
"express": "^5.0.1",
"express-rate-limit": "^6.7.0",
"express-validation": "^1.0.2",
Expand All @@ -40,6 +41,7 @@
"method-override": "^3.0.0",
"mongoose": "^5.2.17",
"morgan": "^1.8.1",
"siwe": "^2.3.2",
"stellar-sdk": "^11.3.0",
"viem": "^2.21.3",
"winston": "^3.1.0"
Expand Down
26 changes: 26 additions & 0 deletions signer-service/src/api/controllers/siwe.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const { createAndSendSiweMessage, verifySiweMessage } = require('../services/siwe.service');

exports.sendSiweMessage = async (req, res) => {
const { walletAddress } = req.body;
try {
const { siweMessage, nonce } = await createAndSendSiweMessage(walletAddress);
return res.json({
siweMessage,
nonce,
});
} catch (e) {
console.error(e);
return res.status(500).json({ error: 'Error while creating siwe message' });
}
};

exports.validateSiweSignature = async (req, res) => {
const { nonce, signature } = req.body;
try {
await verifySiweMessage(nonce, signature);
return res.status(200).json({ message: 'Signature is valid' });
} catch (e) {
console.error(e);
return res.status(500).json({ error: 'Could not validate signature' });
}
};
31 changes: 28 additions & 3 deletions signer-service/src/api/controllers/stellar.controller.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require('dotenv').config();

const { Keypair } = require('stellar-sdk');
const { FUNDING_SECRET } = require('../../constants/constants');
const { FUNDING_SECRET, SEP10_MASTER_SECRET } = require('../../constants/constants');

const { buildCreationStellarTx, buildPaymentAndMergeTx, sendStatusWithPk } = require('../services/stellar.service');
const { signSep10Challenge } = require('../services/sep10.service');
Expand Down Expand Up @@ -54,12 +54,37 @@ exports.changeOpTransaction = async (req, res, next) => {

exports.signSep10Challenge = async (req, res, next) => {
try {
let { clientSignature, clientPublic } = await signSep10Challenge(
let { masterClientSignature, masterClientPublic, clientSignature, clientPublic } = await signSep10Challenge(
req.body.challengeXDR,
req.body.outToken,
req.body.clientPublicKey,
req.body.maybeChallengeSignature,
req.body.maybeNonce,
);
return res.json({ clientSignature, clientPublic });
return res.json({ masterClientSignature, masterClientPublic, clientSignature, clientPublic });
} catch (error) {
if (error.message.includes('Could not verify signature')) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to use internal error codes later.

// Distinguish between failed signature check and other errors.
try {
return res.status(401).json({
error: 'Signature validation failed.',
details: error.message,
});
} catch (error) {
console.error('Error in signSep10Challenge:', error);
return res.status(500).json({ error: 'Failed to sign challenge', details: error.message });
}
}

console.error('Error in signSep10Challenge:', error);
return res.status(500).json({ error: 'Failed to sign challenge', details: error.message });
}
};

exports.getSep10MasterPK = async (req, res, next) => {
try {
const masterSep10Public = Keypair.fromSecret(SEP10_MASTER_SECRET).publicKey();
return res.json({ masterSep10Public });
} catch (error) {
console.error('Error in signSep10Challenge:', error);
return res.status(500).json({ error: 'Failed to sign challenge', details: error.message });
Expand Down
23 changes: 23 additions & 0 deletions signer-service/src/api/middlewares/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,27 @@ const validateSep10Input = (req, res, next) => {
next();
};

const validateSiweCreate = (req, res, next) => {
const { walletAddress } = req.body;
if (!walletAddress) {
return res.status(400).json({ error: 'Missing address: walletAddress' });
}
next();
};

const validateSiweValidate = (req, res, next) => {
const { nonce, signature } = req.body;
if (!signature) {
return res.status(400).json({ error: 'Missing signature: signature' });
}

if (!nonce) {
return res.status(400).json({ error: 'Missing initial nonce: nonce' });
}

next();
};

module.exports = {
validateChangeOpInput,
validateQuoteInput,
Expand All @@ -166,4 +187,6 @@ module.exports = {
validateRatingInput,
validateExecuteXCM,
validateSep10Input,
validateSiweCreate,
validateSiweValidate,
};
6 changes: 6 additions & 0 deletions signer-service/src/api/routes/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const storageRoutes = require('./storage.route');
const emailRoutes = require('./email.route');
const ratingRoutes = require('./rating.route');
const subsidizeRoutes = require('./subsidize.route');
const siweRoutes = require('./siwe.route');
const quoteRoutes = require('./quote.route');

const router = express.Router({ mergeParams: true });
Expand Down Expand Up @@ -76,4 +77,9 @@ router.use('/subsidize', subsidizeRoutes);
*/
router.use('/rating', ratingRoutes);

/**
* POST v1/siwe
*/
router.use('/siwe', siweRoutes);

module.exports = router;
11 changes: 11 additions & 0 deletions signer-service/src/api/routes/v1/siwe.route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const express = require('express');
const controller = require('../../controllers/siwe.controller');
const { validateSiweCreate, validateSiweValidate } = require('../../middlewares/validators');

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

router.route('/create').post(validateSiweCreate, controller.sendSiweMessage);

router.route('/validate').post(validateSiweValidate, controller.validateSiweSignature);

module.exports = router;
2 changes: 2 additions & 0 deletions signer-service/src/api/routes/v1/stellar.route.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ router.route('/payment').post(validateChangeOpInput, controller.changeOpTransact

router.route('/sep10').post(validateSep10Input, controller.signSep10Challenge);

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

module.exports = router;
1 change: 1 addition & 0 deletions signer-service/src/api/services/pendulum.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ exports.sendStatusWithPk = async () => {
fundingAccountKeypair.address,
tokenConfig.pendulumCurrencyId,
);
console.log(tokenBalanceResponse?.free?.toString());

const tokenBalance = Big(tokenBalanceResponse?.free?.toString() ?? '0');
const maximumSubsidyAmountRaw = Big(tokenConfig.maximumSubsidyAmountRaw);
Expand Down
68 changes: 59 additions & 9 deletions signer-service/src/api/services/sep10.service.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,53 @@
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 { CLIENT_DOMAIN_SECRET } = require('../../constants/constants');
const { SEP10_MASTER_SECRET, CLIENT_DOMAIN_SECRET } = require('../../constants/constants');

const NETWORK_PASSPHRASE = Networks.PUBLIC;

exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey) => {
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 getAndValidateMemo = async (nonce, userChallengeSignature) => {
if (!userChallengeSignature || !nonce) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes more sense to put this check out of this function and wrap getAndValidateMemo with the check before calling it. Or give the function a different name. But based on the name I would expect to always get a memo for the nonce and userChallengeSignature, and if I don't that's an error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, with the same signature you will get the same memo, but it's true that the nonce may be confusing given the name.
What if we name it validateSignatureAndGetMemo? If you prefer separated, I guess we can validate first and then derive the memo directly on signSep10Challenge.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm okay with naming it validateSignatureAndGetMemo.

return null; // Default memo value when single stellar account is used
}

let message;
try {
message = await verifySiweMessage(nonce, userChallengeSignature);
} 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) => {
const masterStellarKeypair = Keypair.fromSecret(SEP10_MASTER_SECRET);
const clientDomainStellarKeypair = Keypair.fromSecret(CLIENT_DOMAIN_SECRET);

// 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

let memo;
try {
memo = await getAndValidateMemo(nonce, userChallengeSignature);
} catch (e) {
throw new Error(`Could not verify signature or derive memo: ${e.message}`);
}

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

const transactionSigned = new TransactionBuilder.fromXDR(challengeXDR, NETWORK_PASSPHRASE);
if (transactionSigned.source !== anchorSigningKey) {
Expand All @@ -22,10 +58,10 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey) =>
}

// See https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md#success
// memo field should be empty as we assume (in this implementation) that we use the ephemeral
// to authenticate. But no memo sub account derivation.
if (transactionSigned.memo.value !== null) {
throw new Error('Memo does not match');
// memo field should be empty as we assume for the ephemeral case, or the corresponding evm address
// derivation.
if (transactionSigned.memo.value !== memo) {
throw new Error('Memo does not match with specified user signature or address. Could not validate.');
}

const { operations } = transactionSigned;
Expand All @@ -36,22 +72,29 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey) =>
}

// Only authorize a session that corresponds with the ephemeral client account
// TODO is this really an edge case? Obviously incorrenct, but security concern?
if (firstOp.source !== clientPublicKey) {
throw new Error('First manageData operation must have the client account as the source');
}

if (memo !== null && memoEnabled) {
if (firstOp.source !== masterStellarKeypair.publicKey()) {
throw new Error(
'First manageData operation must have the master signing key as the source when memo is being used.',
);
}
}

if (firstOp.name !== `${homeDomain} auth`) {
throw new Error(`First manageData operation should have key '${homeDomain} auth'`);
}
if (!firstOp.value || firstOp.value.length !== 64) {
throw new Error('First manageData operation should have a 64-byte random nonce as value');
}

// Flags to check presence of required operations
let hasWebAuthDomain = false;
let hasClientDomain = false;

// Verify extra manage_data operations, web_auth and proper client domain.
for (let i = 1; i < operations.length; i++) {
const op = operations[i];

Expand Down Expand Up @@ -88,8 +131,15 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey) =>
clientDomainSignature = transactionSigned.getKeypairSignature(clientDomainStellarKeypair);
}

let masterClientSignature;
if (memo !== null && memoEnabled) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've verified all operations so reaching here should be safe to append the signature.

masterClientSignature = transactionSigned.getKeypairSignature(masterStellarKeypair);
}

return {
clientSignature: clientDomainSignature,
clientPublic: clientDomainStellarKeypair.publicKey(),
masterClientSignature,
masterClientPublic: masterStellarKeypair.publicKey(),
};
};
74 changes: 74 additions & 0 deletions signer-service/src/api/services/siwe.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const siwe = require('siwe');
const { createPublicClient, http } = require('viem');
const { polygon } = require('viem/chains');

// Make constants on config
const scheme = 'https';
const domain = 'satoshipay.io';
const origin = 'https://vortex.com';
const statement = 'Please sign the message to login!';

// Map that will hold the siwe messages sent + nonce we defined
const siweMessagesMap = new Map();

exports.createAndSendSiweMessage = async (address) => {
const nonce = siwe.generateNonce();
const siweMessage = new siwe.SiweMessage({
scheme,
domain,
address,
statement,
uri: origin,
version: '1',
chainId: polygon.id,
nonce,
expirationTime: new Date(Date.now() + 360 * 60 * 1000).toISOString(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What time are you trying to describe here? A year? If so, it's missing 24. Might also be a good idea to put this into an extra constant or possibly make it configurable with env variables.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing specific, as we still need to define the log out and expiration behavior. I'll add it to constants though.

});
const preparedMessage = siweMessage.toMessage();
siweMessagesMap.set(nonce, { siweMessage, address });

return { siweMessage: preparedMessage, nonce };
};

exports.verifySiweMessage = async (nonce, signature) => {
const maybeSiweData = siweMessagesMap.get(nonce);
if (!maybeSiweData) {
throw new Error('Message not found, we have not send this message or nonce is incorrect.');
}

const publicClient = createPublicClient({
chain: polygon,
transport: http(),
});

const valid = await publicClient.verifyMessage({
address: maybeSiweData.address,
message: maybeSiweData.siweMessage.toMessage(),
signature,
});

if (!valid) {
throw new Error('Invalid signature.');
}

// Perform additional checks to ensure message integrity
if (maybeSiweData.siweMessage.nonce !== nonce) {
throw new Error('Nonce mismatch.');
}

if (maybeSiweData.expirationTime && new Date(maybeSiweData.expirationTime) < new Date()) {
throw new Error('Message has expired.');
}

// Successful verification, remove messages with same address
// from map except for the one we just verified.
siweMessagesMap.forEach((data, nonce) => {
if (data.address === maybeSiweData.address && nonce !== maybeSiweData.siweMessage.nonce) {
siweMessagesMap.delete(nonce);
}
});

return maybeSiweData.siweMessage;
};

// TODO we need some sort of session log-out.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that the map doesn't grow indefinitely? Or why would we need a log out? The log out happens 'automatically' when the token expires and the user tries to use it again. So I don't see why we need an explicit logout.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will grow indefinitely for new addresses. This simple way of keeping valid sessions is very rudimentary, that's why I would use a session library if we want more functionality.

Regarding if we need it or not, technically no, but I believe it's just a standard. For instance polkassembly has a log out "feature". Again we need to define this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah maybe we should use sessions. Or at least persist the nonce map so that when the server restarts (due to redeployment or crash) users don't need to always sign in again which would be the case if we only keep an in-memory list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that adding more complexity to this implementation, for instance the persistence after a crash, comes very close to a full session implementation which I would prefer to do with a library already.

I'm even dubious about the logout. Maybe we can skip it for now wdyt? And in the next iteration we use a proper session library.

2 changes: 2 additions & 0 deletions signer-service/src/constants/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require('dotenv').config();
const PENDULUM_FUNDING_SEED = process.env.PENDULUM_FUNDING_SEED;
const FUNDING_SECRET = process.env.FUNDING_SECRET;
const MOONBEAM_EXECUTOR_PRIVATE_KEY = process.env.MOONBEAM_EXECUTOR_PRIVATE_KEY;
const SEP10_MASTER_SECRET = FUNDING_SECRET;
const CLIENT_DOMAIN_SECRET = process.env.CLIENT_DOMAIN_SECRET;

module.exports = {
Expand All @@ -32,5 +33,6 @@ module.exports = {
SUBSIDY_MINIMUM_RATIO_FUND_UNITS,
STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS,
PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS,
SEP10_MASTER_SECRET,
CLIENT_DOMAIN_SECRET,
};
Loading
Loading