How to generate a stable obfuscated string from a serial user ID?

With these characteristics:

  • max 64 characters
  • stable: a given user ID results in the same obfuscated string
  • can be deobfuscated using a secret
  • no need to be stored in DB per record (adds DB clutter and not very scalable when several of them are needed)
  • ideally, is confined to a specific purpose

I saw articles suggesting:

crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[..31])
crypt.encrypt_and_sign(user.id)

But:

  • not stable (encrypt_and_sign(user.id) gives a different string every time, even with the same algorithm, secret, and user ID)
  • length becomes > 64 characters when a purpose is added (although I could live without that)

Would someone have any suggestions?


Background:

When integrating in-app payments with Google Play, an obfuscated version of the user’s account ID can be provided which can help “detect irregular activity, such as many devices making purchases on the same account in a short period of time”. It could also help identify the Rails user account corresponding to a specific Google order in case something went wrong with the reconciliation in the app after the purchase (e.g. race condition with webhooks, bad internet connectivity, bug, etc).

(NB: The documentation is a bit confusing because it talks about an obfuscated account ID but then states “Do not use this field to store any Personally Identifiable Information (PII)”. So maybe it is sufficient to just pass the user ID itself which may not qualify as PII?)

If you only need this as a way to obfuscate it from overly curious eyes, you can get away with something as simple as (id << 3).to_s(33) — that’ll convert it to string using an unconventional base (33) and, additionally, shift the number by a few bits to the side. For example, (1234567890 << 3).to_s(33) will end up being "7lc5c8f", which you can then deobfuscate back with "7lc5c8f".to_i(33) >> 3. If you want to use Rails.application.secret_key_base for extra security, you can do something like this:

shift = Rails.application.secrets.secret_key_base[-1].to_i(36)%3
string = (1234567890 << shift).to_s(30 + shift)

You can do the same with that extra information, too:

shift = 2
("secret".to_i(36) << shift).to_s(30+shift) #=> "6cmejok"
(("6cmejok".to_i(30+shift)) >> shift).to_s(36) #=> "secret"
1 Like

@NeilDouglas Thanks! Would there be a way to use the entire secret_key_base, or as many characters as possible (your example uses a single one: Rails.application.secrets.secret_key_base[-1])?

SignedGlobalID is stable

User.last.to_sgid(expires_at: nil, purpose: 'google-play').to_s

See the GlobalID documentation for more examples:

You can use any character, actually, as the important part here is to get a pseudo-random shift size. The shift is used to: 1. perform a binary shift of the id integer by X bytes (== increase the object’s size by that many bytes, read more about bitwise operations here, for example), and 2. form a pseudo-random base for int.to_s(base) and str.to_i(base). base should be between 2 and 36. So, you’re free to experiment with the shift number as long as you stay within these bounds and understand the consequences of shifting the integer too much :slight_smile: (for example, 123 << 10 results in 125952, and 123 << 100 in 155921023828072216384094494261248).

That said, though, @petrik 's solution looks much simpler and I’d suggest you go with that one instead :slight_smile:

1 Like

Thanks @petrik, to_sgid looked promising, unfortunately the resulting string is much too long, even when the purpose is omitted.

I’ve used HashIDs for this:

2 Likes

For what it’s worth, I would not consider an integer user ID from your system PII. (Not a lawyer though :slight_smile: )

Thanks @prognostikos, this looks like a good solution :smiley:

I wrote this just out of curiosity, and as something to distract myself from the routine tasks…

One of the simplest and most widely known encryption techniques is a Caesar cipher (Caesar cipher - Wikipedia) and it’s descendant, ROT13 (ROT13 - Wikipedia). But for extra security, instead of a static shift, we can use a password/passphrase, which will determine the shift size.

A simplest example would then be as follows:

message = "The quick brown fox jumps over the lazy dog"
cipher = "You can use Rails.application.secret_key_base here,
or any other super-secret string.
It can be as long or as short as you like, since when we
get to the end of the phrase, we can always
rotate to the beginning of it."

To iterate over the charaters in the message, we need to convert it to array of bytes:

message_bytes = message.bytes
=> [84, 104, 101, 32, 113, 117, 105, 99, 107, 32...

Now we have to iterate through each byte, adding to it’s integer value the integer value of corresponding byte from passphrase:

# T  h    e        q    u    i    c   k
[84, 104, 101, 32, 113, 117, 105, 99, 107, 32...
+
# Y  o    u        c    a    n        u    s
[89, 111, 117, 32, 99,  97,  110, 32, 117, 115...

encrypted_bytes = message.bytes.map.with_index do |byte, position|
  byte + cipher.bytes.at(position % cipher.bytes.length)
end
=> [173, 215, 218, 64, 212, 214, 215, 131, 224, 147...
# ` % cipher.bytes.length` is there to ensure we're looping
# the cipher over from the beginning when the message is
# longer than used cipher

OK, it’s a sequence of integers, but how can we get it to be a string again? Simplest option would be to just get the char value of a byte, and be done with it:

encrypted_string = encrypted_bytes.map(&:chr).join
=> "\xAD\xD7\xDA@\xD4\xD6\xD7\x83\xE0\x93...

OK, well, this is not very readable, nor printable, nor can be used in the URL… So, let’s use Base64.urlsafe_encode64 then:

url_safe_string = Base64.urlsafe_encode64(encrypted_string)
=> "rdfaQNTW14Pgk8eSwdjXjNmd2ZDa4dbT1JTY5dOgk9nL14XgwOXemcPRyA"

Alright, we got the url-safe string! Now let’s decipher it back to message:

encrypted_string = Base64.urlsafe_decode64(url_safe_string)
=> "\xAD\xD7\xDA@\xD4\xD6\xD7\x83\xE0\x93..."
encrypted_bytes = encrypted_string.bytes
=> [173, 215, 218, 64, 212, 214, 215, 131, 224, 147...
decrypted_bytes = encrypted_bytes.map.with_index do |byte, position|
  byte - cipher.bytes.at(position % cipher.bytes.length)
end
=> [84, 104, 101, 32, 113, 117, 105, 99, 107, 32...
decrypted_bytes.map(&:chr).join
=> "The quick brown fox jumps over the lazy dog"

And that’s it!

To put it nicely:

def encrypt(message, passphrase)
  pass_bytes = passphrase.bytes
  pass_length = pass_bytes.length
  Base64.urlsafe_encode64(
    message.bytes.map.with_index { |byte, position|
      (byte + pass_bytes.at(position % pass_length)).chr
    }.join,
    padding: false
  )
end

def decrypt(message, passphrase)
  pass_bytes = passphrase.bytes
  pass_length = pass_bytes.length
  Base64.urlsafe_decode64(message).bytes.map.with_index{ |byte, position|
    (byte - pass_bytes.at(position % pass_length)).chr
  }.join
end

encrypted_message = encrypt(message, passphrase)
decrypt(encrypted_message, passphrase) == message
=> true

This approach can then be used to encrypt any data structure that can be Marshaled:

data = {id: 123456789, purpose: "'tis a secret!"}
encrypted_data = encrypt(Marshal.dump(data), passphrase)
# "XXfwJ51o14Ted3rtrWijeOOj0-Df386sg4eQ49ehk8aD5crX0dDZmmWcZ7i5"
decrypted_data = Marshal.load(decrypt(encrypted_data, passphrase))
decrypted_data == data
=> true

And Bob’s your uncle!

:wave:

Thanks @NeilDouglas for the interesting details!