Secure Web3 Authentication
By Anton Pyrogovskyi • 4 minutes read •
Table of Contents
Today I would like to go into some more detail about the authentication mechanism of the Nillion Secret Manager.
Obviously, we would like to ensure that only the user that created a secret has access to it and other users don’t (unless access is granted through the permissions system explicitly). This means we need to be extra careful around user ID generation for the Nillion network to ensure that either accidental or malicious collisions do not happen.
Nillion provides seamless integration with the Keplr wallet to “log in” to Nillion apps. But the fact that the Nillion user ID is decoupled from the wallet keys, while generally beneficial, is getting in our way here! The assumed user ID is independent of the wallet used to log in, ie. you can log in as the same user using different wallets. It is possible to generate a predictable user ID using the same “user seed” every time. Therefore, we need a way to derive the user ID from the wallet identity securely and remember it for later so we can provide secret persistence to the user.
It is not very difficult to find a suitable approach for this but it is also easy to get this wrong.
So here is what I did in commit 78ee659
:
Server-side signature verification
The Keplr Wallet API provides two functions that can be used to establish wallet/key (and therefore user) identity: signArbitrary()
and verifyArbitrary()
. These functions actually do what’s written on the box: they allow one to sign an arbitrary data blob as string
or Uint8Array
with the wallet’s private key and later verify the signature using the wallet’s public key (which corresponds to its address).
Looks good, right? Wait a second…
The Keplr API is entirely client-side since it is a browser plug-in wallet. This means that we can’t really trust what we get from there on the server as the client can trivially spoof JS running in the browser! This way, someone could pretend to own a wallet they do not really own and “prove” this to the server, gaining access to another user’s secrets. That would be really bad.
So the correct approach here is to sign client-side, but verify the signature server-side. The wallet address is encoded in the authentication flow, therefore we can assume that if there is a valid signature by this wallet on a token, the user actually owns the private keys for the given wallet address.
Verifying the signature server-side is not documented well, but is surely possible. I used the verifyADR36Amino()
function from the @keplr-wallet/cosmos
package which also works server-side when imported in Node.js.
Auth challenge/response flow
The actual auth flow looks like this:
- The client requests an “auth token” from the server using its wallet address
- The server generates an auth token which contains
- the client’s wallet address
- a persistent cryptographic salt
- an ephemeral nonce
- an expiry timestamp
- The server signs the auth token with its ed25519 private key
- The client signs the auth token with its wallet private key
- The client sends the signed auth token back to the server
- The server verifies its ed25519 signature on the token
- The server verifies the client’s signature on the token
- If everything looks correct, the client is logged in
Why this particular flow?
Prevent client spoofing
One of the core principles of security is, we can with more likelihood trust our code running on the server to do what we think it does, than any code running on the client that can be spoofed by the client. This is why we generate and sign the auth token on the server. We also generate the salt server-side as we cannot use anything coming from the client as a trusted seed for the user ID.
Prevent replay attacks
In the token, we generate a random unique nonce every time to ensure that no token is the same. We also set an expiration timestamp which prevents a token being replayed in the future from being accepted. This protects from replay attacks by ensuring that the same challenge response is not valid indefinitely.
Prevent server spoofing
The token is signed using a randomly generated (but persistent for the lifetime of the process) ed25519 keypair on the server. On challenge response, we check that we did actually issue this token on the server.
That’s it so far. Stay tuned for more tips and tricks! 🙂