Go Beyond

Written by Teran McKinney
/ About Me / Half-time Remote DevOps/Systems Engineer for $40,000 /

Secure state in encrypted callback URLs

I came up with a way to hold secure state in a callback URL. I'm sure this has been done before, but I'm not sure how well known it is.

In general, I loathe state. I like to keep state as client side as possible. Having minimal places with state lowers your possible source-of-truth footprint. Ideally, there's just one source-of-truth. There's nothing to debate about. And it's much easier to make things geo-redundant with less state to manage.

I just finished refactoring coinfee (update: now defunct) to use a different approach. The endpoint using this behavior is deprecated now. It was kind of useless here, but I think this might have other more interesting applications.

Let's say a client sends a POST to your endpoint. You validate the data and it checks out. Maybe there was some sort of payment or one-time action done. It all looks good, so you give them your callback.

Rather than give them /callback/12345 which you need some kind of table to lookup and see what 12345 is, you can just give them your whole payload.

Pseudo-Python example:

pip install crypto pyyaml

# Should load this from a JSON file or something.
# Keys in code is bad, very bad!
KEY = 'supersecretkey'

BASE_URL = 'https://myservice.net/callback/{}'

# Get the data from the client.
try:
    input_json = json.dumps(json.load(env['wsgi.input']))
    data = yaml.safe_load(input_json)
except:
    return reply(400, 'Where\'s your json?')

key_list = ['foo', 'bar']

# Validate the keys and make sure you only work with those.
for key in key_list:
    if key not in data:
        return reply(400, '{} not in JSON.'.format(key))
    sanitized_data[key] = data[key]

# Add a super secret thing that they don't know about.
sanitized_data['secret'] = 'Too many secrets.'

cipher = Fernet(KEY)
# This returns a base64 string, which is nice.
encrypted = cipher.encrypt(json.dumps(sanitized_data))
return BASE_URL.format(encrypted)

And on the receiving end:

try:
    transaction = path[len('/transaction/'):]
    cipher = Fernet(KEY)
    transaction_json = cipher.decrypt(transaction)
except:
    return reply(400, 'Bad URL.')

data = yaml.safe_load(transaction_json)

# Do stuff with data.

This is kind of scary and awkward. But, it works. May want to put in timestamps and only allow callbacks to be hit within a certain window, depending on what you're trying to do. It also puts your cipher and cryptography library into question, more than ever.

The URLs can get pretty big. Here's one that I generated with this setup: http://coinfee.net/transaction/gAAAAABYHMDGHBMt7y0BOgXBguZJ537CWJtf3uswh3JTmRrf68WQrC2ZR-MW95aErQGigWpavO4MI0GVDpqY15sYHqcXv2t7JIGYK3nMSKLVpb0H7eYu6UJz5c3uHEMlRUYS94i0hozXZEm1yw7Sikr23Vva9rZRitoVVtwHGlLipdMqkger1VvMwRGkKjCysw2_sZipwVX17r8gpPaOKS664yrovju59-d6Mh8JTjNz41JiTQpOkpsZ1x5Yorv3RmEVAGxXuZZY`

EDIT: Thanks to a Redditor, I found out that this has been done already in a better fashion: https://pythonhosted.org/itsdangerous/




Share on Voat.