diff --git a/data-sources/docs/instagram.md b/data-sources/docs/instagram.md new file mode 100644 index 0000000..e00571f --- /dev/null +++ b/data-sources/docs/instagram.md @@ -0,0 +1,53 @@ +# Instagram + +Users can connect their Instagram account by posting a link to the verification data inside the biography. +The verification data must be a JSON object formed as described inside ["Verification data"](../../README.md#verification-data). + +## Example biography + +An example biography can be found [here](https://www.instagram.com/test_desmos_user). + +## Verification process + +The verification process on Instagram is made of the following steps: + +1. The user uploads their verification data to an online storage (eg. PasteBin). +2. The user links the online storage URL to their account by putting it inside their account biography. +3. The user requests Themis to cache their user profile data by an access token with the permission requesting for their profile. +4. The user performs a Desmos transaction telling that they want to link the Instagram account to the Desmos one, and provides the proper call data. + +Once that's done, what will happen is the following: + +1. Desmos will send the call data to Band Protocol, asking to get the Instagram user username and the signature provided by the user. +2. Band Protocol will call the appropriate data source that will use our APIs to get the data from the account biography. +3. Once downloaded, the data source will check the validity of the data and return to Desmos the user username and signature. +4. If the Instagram user username matches the one provided by the user, and the signature is valid against the user public key, the Desmos and Instagram account will be linked together. + +## Data source call data + +When asking to verify the ownership of a Instagram account, the data source call data must be a JSON object formed as follows: + +```json +{ + "username": "" +} +``` + +Example: + +```json +{ + "username":"test_desmos_user" +} +``` + +Hex encoded: +``` +7b22757365726e616d65223a22746573745f6465736d6f735f75736572227d +``` + +Example execution: + +```shell +python Instagram.py 7b22757365726e616d65223a22746573745f6465736d6f735f75736572227d +``` diff --git a/data-sources/instagram.py b/data-sources/instagram.py new file mode 100644 index 0000000..7e4bd7a --- /dev/null +++ b/data-sources/instagram.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +import json +import sys +import requests +import re +from typing import Optional +import cryptography.hazmat.primitives.asymmetric.utils as crypto +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import hashes +import hashlib + +ENDPOINT = "https://themis.mainnet.desmos.network/instagram" +HEADERS = {"Content-Type": "application/json"} + + +class CallData: + """ + Contains the data that has been used to call the script + """ + + def __init__(self, username: str): + self.username = username + + +class VerificationData: + """ + Contains the data needed to verify the proof submitted by the user. + """ + + def __init__(self, address: str, pub_key: str, value: str, signature: str): + self.address = address + self.pub_key = pub_key + self.signature = signature + self.value = value + + +def get_urls_from_biography(user: str) -> [str]: + """ + Returns all the URLs that are found inside the biography of the user having the given user username. + :param user: Username of the Instagram user. + :return: List of URLs that are found inside the biography + """ + url = f"{ENDPOINT}/users/{user}" + result = requests.request("GET", url, headers=HEADERS).json() + return re.findall(r'(https?://[^\s]+)', result['biography']) + + +def get_signature_from_url(url: str) -> Optional[VerificationData]: + """ + Tries getting the signature object linked to the given URL. + :param url: URL that should contain the signature object. + :return: A dictionary containing 'valid' to tell whether the search was valid, and an optional 'data' containing + the signature object. + """ + try: + result = requests.request("GET", url, headers=HEADERS).json() + if validate_json(result): + return VerificationData( + result['address'], + result['pub_key'], + result['value'], + result['signature'], + ) + else: + return None + except ValueError: + return None + + +def validate_json(json: dict) -> bool: + """ + Tells whether or not the given JSON is a valid signature JSON object. + :param json: JSON object to be checked. + :return: True if the provided JSON has a valid signature schema, or False otherwise. + """ + return all(key in json for key in ['value', 'pub_key', 'signature', 'address']) + + +def verify_signature(data: VerificationData) -> bool: + """ + Verifies the signature using the given pubkey and value. + :param data: Data used to verify the signature. + :return True if the signature is valid, False otherwise + """ + if len(data.signature) != 128: + return False + + try: + # Create signature for dss signature + (r, s) = int(data.signature[:64], 16), int(data.signature[64:], 16) + sig = crypto.encode_dss_signature(r, s) + + # Create public key instance + public_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), bytes.fromhex(data.pub_key)) + + # Verify the signature + public_key.verify(sig, bytes.fromhex(data.value), ec.ECDSA(hashes.SHA256())) + return True + except Exception: + return False + + +def verify_address(data: VerificationData) -> bool: + """ + Verifies that the given address is the one associated with the provided HEX encoded compact public key. + :param data: Data used to verify the address + """ + s = hashlib.new("sha256", bytes.fromhex(data.pub_key)).digest() + r = hashlib.new("ripemd160", s).digest() + return data.address.upper() == r.hex().upper() + + +def check_values(values: dict) -> CallData: + """ + Checks the validity of the given dictionary making sure it contains the proper data. + :param values: Dictionary that should be checked. + :return: A CallData instance. + """ + if "username" not in values: + raise Exception("Missing 'username' value") + + return CallData(values["username"]) + + +def main(args: str): + """ + Gets the signature data from Instagram, after the user has provided it through the Hephaestus bot. + + :param args Hex encoded JSON object containing the arguments to be used during the execution. + In order to be valid, the encoded JSON object must contain one field named "username" that represents the Instagram + username of the account to be connected. + + Example argument value: + 7B22757365726E616D65223A22526963636172646F204D6F6E7461676E696E2335343134227D + + This is the hex encoded representation of the following JSON object: + + ```json + { + "username":"test_user" + } + ``` + + :param args: JSON encoded parameters used during the execution. + :return The signed value and the signature as a single comma separated string. + :raise Exception if anything is wrong during the process. This can happen if: + 1. The Instagram user has not started the connection using the Hephaestus bot + 2. The provided signature is not valid + 3. The provided address is not linked to the provided public key + """ + + decoded = bytes.fromhex(args) + json_obj = json.loads(decoded) + call_data = check_values(json_obj) + + # Get the URLs to check from the user biography + urls = get_urls_from_biography(call_data.username) + if len(urls) == 0: + raise Exception(f"No URL found inside {call_data.username} biography") + + # Find the signature following the URLs + data = None + for url in urls: + result = get_signature_from_url(url) + if result is not None: + data = result + break + + if data is None: + raise Exception(f"No valid signature data found inside {call_data.username} biography") + + # Verify the signature + signature_valid = verify_signature(data) + if not signature_valid: + raise Exception("Invalid signature") + + # Verify the address + address_valid = verify_address(result) + if not address_valid: + raise Exception("Invalid address") + + return f"{result.value},{result.signature},{call_data.username}" + + +if __name__ == "__main__": + try: + print(main(*sys.argv[1:])) + except Exception as e: + print(str(e), file=sys.stderr) + sys.exit(1) diff --git a/data-sources/instagram_test.py b/data-sources/instagram_test.py new file mode 100644 index 0000000..e2d4411 --- /dev/null +++ b/data-sources/instagram_test.py @@ -0,0 +1,181 @@ +import unittest + +import httpretty + +import instagram + + +class TestInstagram(unittest.TestCase): + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_urls_from_biography(self): + # Register fake HTTP call + httpretty.register_uri( + httpretty.GET, + "https://themis.mainnet.desmos.network/instagram/users/riccardomontagnin", + status=200, + body='{"biography":"https://pastebin.com/raw/TgSpUCz6"}', + ) + + url = instagram.get_urls_from_biography('riccardomontagnin') + self.assertEqual(['https://pastebin.com/raw/TgSpUCz6'], url) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_signature_from_url(self): + # Register fake HTTP call + httpretty.register_uri( + httpretty.GET, + "https://pastebin.com/raw/xz4S8WrW", + status=200, + body='{"address":"desmos13yp2fq3tslq6mmtq4628q38xzj75ethzela9uu","pub_key":"033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d","signature":"a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173","value":"ricmontagnin"}', + ) + + # Valid signature + data = instagram.get_signature_from_url('https://pastebin.com/raw/xz4S8WrW') + self.assertIsNotNone(data) + + # Register fake HTTP call + httpretty.register_uri( + httpretty.GET, + "https://bitcoin.org", + status=200, + body='Bitcoin website', + ) + + # Invalid signature + data = instagram.get_signature_from_url('https://bitcoin.org') + self.assertIsNone(data) + + def test_validate_json(self): + jsons = [ + { + 'name': 'Valid JSON', + 'json': { + 'address': '8902A4822B87C1ADED60AE947044E614BD4CAEE2', + 'pub_key': '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + 'signature': 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173', + 'value': 'ricmontagnin' + }, + 'valid': True + }, + { + 'name': 'Missing address', + 'json': { + 'pub_key': '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + 'signature': 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173', + 'value': 'ricmontagnin' + }, + 'valid': False + }, + { + 'name': 'Missing pub_key', + 'json': { + 'address': '8902A4822B87C1ADED60AE947044E614BD4CAEE2', + 'signature': 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173', + 'value': 'ricmontagnin' + }, + 'valid': False + }, + { + 'name': 'Missing signature', + 'json': { + 'address': '8902A4822B87C1ADED60AE947044E614BD4CAEE2', + 'pub_key': '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + 'value': 'ricmontagnin' + }, + 'valid': False + }, + { + 'name': 'Missing value', + 'json': { + 'address': '8902A4822B87C1ADED60AE947044E614BD4CAEE2', + 'pub_key': '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + 'signature': 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173', + }, + 'valid': False + }, + ] + + for json in jsons: + result = instagram.validate_json(json['json']) + self.assertEqual(json['valid'], result, json['name']) + + def test_verify_signature(self): + tests = [ + { + 'name': 'Valid data', + 'valid': True, + 'data': instagram.VerificationData( + '', + '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + '7269636d6f6e7461676e696e', + 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173', + ), + }, + { + 'name': 'Invalid value', + 'valid': False, + 'data': instagram.VerificationData( + '', + '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173', + 'ricmontagni', + ), + }, + { + 'name': 'Invalid signature', + 'valid': False, + 'data': instagram.VerificationData( + '', + '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + 'a00a7d5bd45e42615645fcaeb4d800af2704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173', + '7269636d6f6e7461676e696e', + ), + }, + { + 'name': 'Invalid pub key', + 'valid': False, + 'data': instagram.VerificationData( + '', + '033024e9e0ad4f9305ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173', + '7269636d6f6e7461676e696e', + ), + }, + ] + + for test in tests: + result = instagram.verify_signature(test['data']) + self.assertEqual(test['valid'], result, test['name']) + + def test_verify_address(self): + tests = [ + { + 'name': 'Valid address', + 'valid': True, + 'data': instagram.VerificationData( + '8902A4822B87C1ADED60AE947044E614BD4CAEE2', + '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b417d', + '7269636d6f6e7461676e696e', + 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173' + ), + }, + { + 'name': 'Invalid address', + 'valid': False, + 'data': instagram.VerificationData( + '8902A4822B87C1ADED60AE947044E614BD4CAEE2', + '033024e9e0ad4f93045ef5a60bb92171e6418cd13b082e7a7bc3ed05312a0b41', + '7269636d6f6e7461676e696e', + 'a00a7d5bd45e42615645fcaeb4d800af22704e54937ab235e5e50bebd38e88b765fdb696c22712c0cab1176756b6346cbc11481c544d1f7828cb233620c06173' + ), + }, + ] + + for test in tests: + result = instagram.verify_address(test['data']) + self.assertEqual(test['valid'], result, test['name']) + + +if __name__ == '__main__': + unittest.main()