-
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?
Authenticate recurrent users with memo. #257
Conversation
✅ Deploy Preview for pendulum-pay ready!
To edit notification comments on pull requests, go to your Netlify site configuration. |
return res.json({ clientSignature, clientPublic }); | ||
return res.json({ masterClientSignature, masterClientPublic, clientSignature, clientPublic }); | ||
} catch (error) { | ||
if (error.message.includes('Could not verify signature')) { |
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.
@@ -88,8 +123,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 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.
src/hooks/useSignChallenge.ts
Outdated
@@ -0,0 +1,123 @@ | |||
import { useEffect, useState, useCallback, useRef } from 'react'; |
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.
Main hook to get, refresh signature and show the sign modal.
return null; | ||
} | ||
}, [address, storageKey]); | ||
|
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 want to return a promise that will resolve when the signing finishes (or fails). This is so we can use checkAndWaitForSignature
and it's force counterpart on the sep-10
process function.
const message = new SiweMessage(siweMessage); | ||
const signature = await signMessageAsync({ message: siweMessage }); | ||
|
||
const validationResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/validate`, { |
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.
Mostly so that an old session is removed. Otherwise we may not need to validate, and we can save the roundtrip.
return signMessage(); | ||
}, [storageKey, signMessage]); | ||
|
||
// ask for signature on address change and on init |
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.
I found this a bit invasive and annoying. But could be enabled. Perhaps with a timer.
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.
I think we should enable it so that users are immediately requested to log in after connecting the wallet. Makes sense to combine these two events so that the user doesn't need to sign thrice later.
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.
I'll enable it so we can test it. I worry a bit that is invasive in the sense that if the user only wants to see quotes or offramp EURC
, it's not needed and may scare some users.
On the other hand it's even more spaced from the other 2 signatures so "feels" better.
async function getUrlParams(ephemeralAccount: string, supportsClientDomain: boolean): Promise<URLSearchParams> { | ||
if (supportsClientDomain) { | ||
return new URLSearchParams({ account: ephemeralAccount, client_domain: config.applicationClientDomain }); | ||
//A memo derivation. TODO: Secure? how to check for collisions? |
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.
Hash the address, and get the base 10 representation of the hash. Apparently the memo needs to be numeric.
} | ||
|
||
const sep10SignaturesWithLoginRefresh = async ( |
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.
Helper to retry once if the service returns a signature error, with a force refresh in the middle.
This is for the case the signer service has invalidated the signature, for some reason (it restarted or the user logged in elsewhere and then came back to use the old session)
src/services/anchor/index.ts
Outdated
transactionSigned.toXDR(), | ||
outputToken, | ||
accountId, | ||
let signatureData = await checkAndWaitForSignature(); |
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 will fetch the signature, if it's invalid or doesn't exist, will ask the user to sign and only then continue.
sep24Params = new URLSearchParams({ | ||
asset_code: sessionParams.tokenConfig.stellarAsset.code.stringStellar, | ||
amount: sessionParams.offrampAmount, | ||
account: sep10Account, // THIS is a particularity of Anclap. Should be able to work without it, or with a different one | ||
// to that of the sep-10 | ||
account: sep10Account, // THIS is a particularity of Anclap. Should be able to work just with the epmhemeral account |
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 are still waiting for Anclap to solve on their side, or tell us if we are sending something wrong.
The commented memo
parameter is deprecated... but it could be the case that will be required.
@pendulum-chain/devs You can try this one locally, including Anclap until the offramp UI (or use the "testing" credentials to interact) |
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.
Interesting approach with SIWE. I almost feel like it's too sophisticated for our use case and we can live with something simpler but since you already made it work, I'm happy to keep it as it's better security-wise.
// 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 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.
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.
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
.
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.
I'm okay with naming it validateSignatureAndGetMemo
.
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 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.
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.
Nothing specific, as we still need to define the log out and expiration behavior. I'll add it to constants though.
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 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.
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.
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 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.
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.
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.
|
||
const storageKey = `${storageKeys.SIWE_SIGNATURE_KEY_PREFIX}${address}`; | ||
|
||
const checkStoredSignature = useCallback((): SiweSignatureData | null => { |
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.
This is missing a check for different addresses. At the moment, it does not check whether the address passed into useSiweSignature
is the same as the one in the localStorage. Thus, we would run into issues when switching between accounts.
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.
The function will refresh to a change in storageKey
and address
as the hook useSiweSignature
itself depends on address
.
I just keep address
also in the array to filter for undefined
values.
src/hooks/useSignChallenge.ts
Outdated
|
||
// Used to wait for the modal interaction and/or return of the | ||
// signing promise. | ||
const signPromiseRef = useRef<{ |
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.
Why are we not using useState
for this?
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.
This one was interesting. At first I tried that, but since checkAndWaitForSignature
and the force
counterpart were being used in the sep-10
function which is not a hook, I was experiencing issues and the state was not reflecting the change.
I later changed the implementation (before I was polling for the signature with a setTimeout
) so it might be that there is no issue storing the promise in a simple state. I would have to test again.
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.
Update: Okay so with this implementation it works with useState
. With previous the issue was that it was checking the state to resolve the promise outside of the hook, but the promise now is being resolved using the values inside the useSiweSignature
hook, that must be it.
return signMessage(); | ||
}, [storageKey, signMessage]); | ||
|
||
// ask for signature on address change and on init |
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.
I think we should enable it so that users are immediately requested to log in after connecting the wallet. Makes sense to combine these two events so that the user doesn't need to sign thrice later.
src/constants/localStorage.ts
Outdated
@@ -10,6 +10,7 @@ export const storageKeys = { | |||
ANCHOR_SESSION_PARAMS: 'ANCHOR_SESSION_PARAMS', | |||
STELLAR_OPERATIONS: 'STELLAR_OPERATIONS', | |||
TOKEN_BRIDGED_AMOUNT: 'TOKEN_BRIDGED_AMOUNT', | |||
SIWE_SIGNATURE_KEY_PREFIX: 'siwe-signature-', |
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.
Is the capitalization format required like this? If it's not, I'd say we make it similar to the others and capitalize to SIWE_SIGNATURE_
src/hooks/useMainProcess.ts
Outdated
type ExtendedExecutionInput = ExecutionInput & { stellarEphemeralSecret: string }; | ||
|
||
export const useMainProcess = () => { | ||
export const useMainProcess = ({ checkAndWaitForSignature, forceRefreshAndWaitForSignature }: UseMainProcessProps) => { |
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.
Since useMainProcess
is a hook itself, you can call other hooks in it. Thus, you don't need to pass these two functions as props but you can just call useSignChallenge
inside the body of useMainProcess
and access the respective functions.
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.
The thing is that we need requiresSign
, handleCancel
, etc for the modal component, which is on the same level as useMainProcess
.
And those 2 functions as a parameter in the sep-10
which is inside useMainProcess
. Alternatively we can set a context but seemed like more trouble, or return the modal states from useMainProcess
.
I already faced the issue of using useSignChallenge
on both levels and doesn't work of course.
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.
Then we might want to move parts of the useSignChallenge
hook into a context so that it's shared and every call to useSignChallenge
returns the shared values. A similar example would be the useBridgeSettings
hook in the portal that uses a shared context for storing the settings a user chose between issue and redeem requests.
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.
Moved to a context, I followed the events
one which I found quite clean. This is what you had in mind?
Is this more or less what you had in mind? I don't think we can move only partially the hook into a context.
src/services/anchor/index.ts
Outdated
} catch (error: any) { | ||
if (error.message === 'Invalid signature') { | ||
let { nonce, signature } = await refreshFunction(); | ||
let regreshedArgs = { ...args, maybeChallengeSignature: signature, maybeNonce: nonce }; |
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.
let regreshedArgs = { ...args, maybeChallengeSignature: signature, maybeNonce: nonce }; | |
let refreshedArgs = { ...args, maybeChallengeSignature: signature, maybeNonce: nonce }; |
@gianfra-t as I don't have ARS, I tried to offramp EUR. When I click on the Confirm button, I am presented the sign in popup. When I click on "Sign Message", nothing happens, the popup just disappears and the form is shown again. If I click on the Confirm button again, the same happens – indefinitely. Is it even intended that the popup is shown in the first place? The identity verification via signature is not required for Mykobo. |
This behavior is not intended definitely. What the console shows? We need some more error handling from the UI side, perhaps a toast, I left that open on purpose since there was also no agreement on that. Nevertheless, for me there is no issue when selecting EURC and it works the same as before. So I imagine there must be a variable missing when you run Also we need to add a condition to NOT ask for signature with EURC or any token that doesn't support memo on config (this, I forgot), although I will not add it now until I understand what happens in your case. |
Yes you need to run it locally. Sadly we don't deploy a service preview so far. |
@@ -177,6 +182,7 @@ export const SwapPage = () => { | |||
outputTokenType: to as OutputTokenType, | |||
amountInUnits: fromAmountString, | |||
offrampAmount: tokenOutAmountData.roundedDownQuotedAmountOut, | |||
setInitializeFailed, |
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.
I realized we had no action for when handleOnSubmit
failed. Probably this doesn't happen to often since sep-10
is quite reliable, but now we add the message sign in the middle which could fail from the s. service.
With this, we will just show the Application initialization fail...
message in this case. Same as on the pre-flight check. Let me know what you think.
I didn't review the entire code yet, but when I tested locally I found the following.
|
Nice observations @TorstenStueber:
It is true that the message can be created either in the backend, or in the frontend. But even when created on the fronted, we need an endpoint that sends a random nonce or a way to derive the nonce such that we are able to verify it on the service, which seems more complex. The nonce is to prevent replay attacks, more info here. Now it is true that the message could be generated on the frontend and then validate the values on the backend. See this example from wagmi. Still, the nonce is fetched. So I thought it was better to abstract the message creation to the backend already. I don't see much benefits/cons on either way.
Yes, I thought of using vortex main domain, but I will try to find a way to remove the warning for the staging and previews.
We were discussing it here, if we add a signature when the app loads/address changes or just when clicking confirm and the asset requires it. So far it's enabled to test how it "feels".
I didn't think about this case! Will add a reset. |
As long as the user is not malicious, they will protected from replay attacks if we create the nonce in the frontend. For malicious users – as you point out – they can just create the message anyway and not use the backend. If there are equal solutions, I would always pick the simpler one – in this case this means to handle this completely in the frontend. Also: this would make it easier to just use the current domain for the message so that Metamask does not show the warning.
This is a user facing decision and we should escalate to @pendulum-chain/product. I will move the discussion elsewhere. |
The issue with replay attacks, as I understand, is to protect from actors picking up the signature of a user and re-using it indefinitely to authenticate as them. Without nonce checking on the backend, getting a signature would essentially like getting the password of the "wallet" only that this password cannot be changed. I think that especially in this case that we store the signature on the local storage we should implement something like this. |
@@ -310,7 +316,10 @@ export const SwapPage = () => { | |||
<TermsAndConditions /> | |||
<PoolSelectorModal | |||
open={!!modalType} | |||
onSelect={modalType === 'from' ? onFromChange : onToChange} | |||
onSelect={(token) => { |
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.
I don't think we were doing this, but we should cancel the offramp both on input and output token selection, if we are in the Continue with partner..
stage.
That, or disable the selection.
Closes: #237.
Context and Preliminaries.
This PR adds a way to authenticate users by means of a signature using their wallet (commonly called: Sign in with web3, SIWE). The signer-service will use this signature in a
JWT
fashion to authorize the signing of the sep-10 operations from the signer-service.The motivation for these changes is to add a scheme where the application is able to authenticate the users by it's initial Polygon/EVM address and avoid re-do of KYC and storing extra credentials.
Detailed overview
To identify the users based on their authenticated address, the sep-10 operations now correspond to the specification of a
memo
that is derived uniquely and in a deterministic way only from the EVM address.The user identifier is then the combination of a
master
account concatenated with thememo
(master:memo
) and must be seen by the Anchor as a completely different user than one authenticated with only themaster
account. (relevant section of the protocol)Only with a valid signature corresponding to the EVM address in question will the signer-service respond with the corresponding signatures, which verifies:
memo
,client-domain
andmaster
account.Security guarantees
Without these signatures from the service, the
sep-10
will fail when requested to the Anchor. Although it is not stated on the protocol (or I missed it) it is expected that the Anchor would ONLY allow amemo
specification when theclient-domain
is defined, and valid. Apparently, anchors could also whitelist themaster
account for this purposes.Flow of operations
Session
So far, the concept of session is simply kept by means of the signature stored in the UI (local storage...). Again, drawing parallels to a
JWT
. The message signed has a date parameter checked by the signer-service.Alternatively, if the user signs from another browser/machine etc, then the old signature will be invalidated on the signer-service.
Requirements for log-out have not been defined yet.
Security risks, and potential improvements.
This is as far as I would go with this simple/prototypical implementation of using the signature as means of authentication.
There is a potential risk since this will be stored in the
localStorage
, unlike secure cookies. Therefore, if we are going to aim for a comprehensive session management, I believe we should implement something likeiron-session
to keep the session, while the initiation would still be by means of this signature verification. This seems to be the common standard practice.Extra libraries concerns.
This pr adds the library siwe which is useful for creating and parsing the message, and potentially verifying. This is useful yet not critical, and the issue is that it requires
ethers
dependency. So to be more compatible with our current libraries and have the freedom to remove this one, we useviem
to verify on the backend side.