-
Notifications
You must be signed in to change notification settings - Fork 1
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
base: polygon-prototype-staging
Are you sure you want to change the base?
Changes from 20 commits
9778b62
72839fb
3c3ac6a
32a3d5a
db71c33
d8f2b2c
7d91840
d0202f2
780b17b
1996e64
b4eaddf
79beb77
9fae39e
ec8e798
2b14bbe
c3bcbd1
bf5d521
9e92784
a6be1ed
9f671b5
9f064b9
4db9ef8
cd844f1
2b18ed0
22f2339
c6d74f2
e12b1fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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' }); | ||
} | ||
}; |
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; |
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm okay with naming it |
||
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) { | ||
|
@@ -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; | ||
|
@@ -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]; | ||
|
||
|
@@ -88,8 +131,15 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey) => | |
clientDomainSignature = transactionSigned.getKeypairSignature(clientDomainStellarKeypair); | ||
} | ||
|
||
let masterClientSignature; | ||
if (memo !== null && memoEnabled) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(), | ||
}; | ||
}; |
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(), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
There was a problem hiding this comment.
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.