If you've ever implemented any kind of SSO, you'll have encountered "relay state". Relay state is a parameter you send to your identity party, and they send it back to you without any modification so you can identify the user who just authorized.
It's a pretty common flow, used by Google as well as many other OAuth providers.
When I was a new programmer, I simply sent the user_id
of the user as relay state and did a User.find(user_id)
to fetch user.
Why do I need to encrypt User ID
So, imagine this, if you signed up for my service, and then later wanted to add your Google credentials, you'd click on a button and it'll take you through the whole authorization workflow, in the end making a Webhook request with relay state as your user id, in plain text.
This was pretty bad as now some user capable of doing Inspect element can change the user_id
from their number to any integer and my application would think the other user has authorized. Now they can use "Login with Google" on the sign in page and simply log in as that other user.
Insecure af.
When I was adding a Stride integration to my app which notifies when a certificate is going to expire soon, I had the same problem as their official docs ask us to add a button where you add a relayState
parameter. Their application sends us a webhook once the user has added our app. The user is not redirected back to our site, unlike Slack.
So the only way of identifying the user was with that relayState
and leaving it to plan user_id
would mean if you change the button's value and click on it, you will potentially get notified when other user's certificates are going to expire.
Encrypting the User ID
To combat this issue, I added two little functions to my helper class and called them encrypt
and decrypt
. The functions looked like this:
# Assuming your Secret Key Base is in Rails.application.secrets.secret_key_base
def encrypt text
text = text.to_s unless text.is_a? String
len = ActiveSupport::MessageEncryptor.key_len
salt = SecureRandom.hex len
key = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base).generate_key salt, len
crypt = ActiveSupport::MessageEncryptor.new key
encrypted_data = crypt.encrypt_and_sign text
"#{salt}$$#{encrypted_data}"
end
def decrypt text
salt, data = text.split "$$"
len = ActiveSupport::MessageEncryptor.key_len
key = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base).generate_key salt, len
crypt = ActiveSupport::MessageEncryptor.new key
crypt.decrypt_and_verify data
end
How encrypt
works
Encrypting a text
requires a key
and salt
. Decrypting an encrypted text
requires the same key
and salt
, otherwise this won't work.
We generate a salt with these lines:
len = ActiveSupport::MessageEncryptor.key_len
salt = SecureRandom.hex len
Once we have our salt, we can use the Rails' secret_key_base
as a "key" to generate a cryptographic key.
key = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base).generate_key salt, len
Using this key, we create an encryption object crypt
crypt = ActiveSupport::MessageEncryptor.new key
and then we encrypt our text via:
encrypted_data = crypt.encrypt_and_sign text
Now, we could have simply returned this encrypted_data
and provided this as a relay state, but since salt
was generated randomly, we would know what it was and so won't be able to decrypt this data.
I used a trick from Rails' has_secure_password
and bcrypt
, and returned a string which contains salt
and encrypted_data
as you can see from the last line of encrypt
method:
"#{salt}$$#{encrypted_data}"
This returns a string where you have both salt
and encrypted_data
and the user can't change this to other user's ID without knowing your secret_key_base
.
How decrypt
works
Once you understand the encrypt
method, decrypt
id fairly straightforward.
The decrypt
method accepts a text
parameter, from which it extracts salt
and data
(or rather encrypted_data
).
salt, data = text.split("$$")
Once you have the salt with us, we create the crypt
object, just like we did in the encrypt
method:
len = ActiveSupport::MessageEncryptor.key_len
key = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base).generate_key salt, len
crypt = ActiveSupport::MessageEncryptor.new key
And then, we decrypt the data:
crypt.decrypt_and_verify data
which we return back from the method.
Conclusion
This is a pretty simple method of encrypting and decrypting a user_id
or any other data.
In an ideal scenario, you would create a table and keep a user's token for an application, which you would send as a relayState
and use it again to identify the user back. It takes time to get that perfect, so this simple encryption and decryption works amazing to get up and running as quickly as possible.
But in the long run, you'd build out a table to keep all this information secure and more robust. That's what I've done with my app. :)
If you're an expert in security, could you tell me if there's something that I've done here which would make it unsecure? Thanks!
Top comments (4)
You might be interested in this thing called StringEncrypt.
stringencrypt.com/
It can take your strings, encrypt them with random algorithm and generate clean decryption Ruby code, for example:
Hey Shobhit, thank you for this thread it helped me quite a bit :)
I'm leaving a contribution since I add to change it it a little bit in order to transmit the encrypted data across servers without the need for a string splitter to get the
salt
.So instead of a
salt
I use anIV
(Initialization Vector).I've also changed from
SecureRandom.hex
toSecureRandom.random_bytes
to get the same amount of chars than the length. (hex produces the double amount) and introduced an ENV var instead of the Rails secret_key_base so I can share the password easily between servers.Hope it helps someone :)
Hi, and thanks for the clean post. I am more curious about how would you do that in Ruby? I used ActiveSupport separately from rails but its a huge dependency, can you suggest any other way for simple string encryption?
Yes, you can do it, let me go through some notes and write up a post real quick.