Skip to content

Instantly share code, notes, and snippets.

@DonnchaC
Last active August 29, 2015 14:16
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DonnchaC/8c7b705402420b77f769 to your computer and use it in GitHub Desktop.
Save DonnchaC/8c7b705402420b77f769 to your computer and use it in GitHub Desktop.
Snippets of code for calculating Tor hidden service descriptor values. Based on the descriptors in https://trac.torproject.org/projects/tor/ticket/15004#comment:4
import unittest
import hashlib
import struct
import binascii
from base64 import b32decode, b64decode
import Crypto.Util
from Crypto.PublicKey import RSA
from Crypto.Cipher import AES
from Crypto.Util import Counter
from Crypto.Util.number import bytes_to_long
def byte_to_int(byte):
return int(binascii.hexlify(byte), 16)
# Key Calculations
def get_asn1_sequence(rsa_key):
seq = Crypto.Util.asn1.DerSequence()
seq.append(rsa_key.n)
seq.append(rsa_key.e)
asn1_seq = seq.encode()
return asn1_seq
def calc_key_digest(rsa_key):
"""Calculate the SHA1 digest of an RSA key"""
return hashlib.sha1(get_asn1_sequence(rsa_key)).digest()
def calc_permanent_id(rsa_key):
return calc_key_digest(rsa_key)[:10]
# Descriptor Calculations
def get_time_period(time, permanent_id):
"""
time-period = (current-time + permanent-id-byte * 86400 / 256) / 86400
"""
permanent_id_byte = int(struct.unpack('B', permanent_id[0])[0])
return (time + permanent_id_byte * 86400 / 256) / 86400
def calc_secret_id_part(time_period, replica, descriptor_cookie=None):
"""
secret-id-part = H(time-period | descriptor-cookie | replica)
"""
secret_id_part = hashlib.sha1()
secret_id_part.update(struct.pack('>I', time_period)[:4])
if descriptor_cookie:
secret_id_part.update(descriptor_cookie)
secret_id_part.update('{0:02X}'.format(replica).decode('hex'))
return secret_id_part.digest()
def calc_descriptor_id(permanent_id, replica, time, descriptor_cookie=None):
time_period = get_time_period(time, permanent_id)
secret_id_part = calc_secret_id_part(time_period, replica,
descriptor_cookie)
descriptor_id = hashlib.sha1(permanent_id + secret_id_part).digest()
return descriptor_id
def parse_encrypted_introduction_points(introduction_point_blob,
descriptor_cookie):
BASIC_AUTH = 1
STEALTH_AUTH = 2
authorization_type = byte_to_int(introduction_point_blob[0])
if authorization_type is BASIC_AUTH:
client_blocks = byte_to_int(introduction_point_blob[1])
# Parse the client ID's / encrypted session keys
client_entries_length = client_blocks * 16 * 20
client_entries = introduction_point_blob[2:2 + client_entries_length]
client_keys = [(client_entries[i:i+4], client_entries[i+4:i+20])
for i in range(0, client_entries_length, 4 + 16)]
# Parse the IV and encrypted introduction points after the client keys
iv = introduction_point_blob[2 + client_entries_length:
2 + client_entries_length + 16]
encrypted = introduction_point_blob[2 + client_entries_length + 16:]
# Calculate our client ID
client_id = hashlib.sha1(descriptor_cookie + iv).digest()[:4]
for entry_id, encrypted_session_key in client_keys:
# Find the session key encrypted for this client
if entry_id == client_id:
# Try decrypt the session key
cipher = AES.new(descriptor_cookie, AES.MODE_CTR,
counter=Counter.new(128, initial_value=0))
session_key = cipher.decrypt(encrypted_session_key)
# Attempt to decrypt the intro points with the session key
cipher = AES.new(
session_key, AES.MODE_CTR,
counter=Counter.new(128, initial_value=bytes_to_long(iv))
)
decrypted = cipher.decrypt(encrypted)
# Check if the decryption looks correct
if decrypted.startswith("introduction-point "):
return decrypted
# If we get here, something went wrong :(
raise RuntimeError("The introduction points were not decrypted "
"successfully")
elif authorization_type is STEALTH_AUTH:
iv = introduction_point_blob[1:1 + 16]
encrypted = introduction_point_blob[1 + 16:]
cipher = AES.new(
descriptor_cookie, AES.MODE_CTR,
counter=Counter.new(128, initial_value=bytes_to_long(iv))
)
decrypted = cipher.decrypt(encrypted)
if decrypted.startswith("introduction-point "):
return decrypted
else:
raise RuntimeError("The introduction points were not decrypted "
"successfully")
class TestDescriptorCalculations(unittest.TestCase):
def setUp(self):
self.skipTest('Parent Test Case')
def test_calc_permanent_id(self):
permanent_id = calc_permanent_id(self.permanent_key)
self.assertEqual(b32decode(self.onion_address, 1), permanent_id)
def test_calc_secret_id_part(self):
permanent_id = calc_permanent_id(self.permanent_key)
time_period = get_time_period(self.timestamp, permanent_id)
# Generate the secret_id_parts for both replicas
secret_id_parts = []
for replica in range(0, 2):
secret_id_parts.append(
calc_secret_id_part(time_period=time_period, replica=replica,
descriptor_cookie=self.descriptor_cookie)
)
self.assertTrue(b32decode(self.secret_id_part, 1) in secret_id_parts)
def test_calc_descriptor_id(self):
permanent_id = calc_permanent_id(self.permanent_key)
# Generate the descriptor_id's for both replicas
descriptor_ids = []
for replica in range(0, 2):
descriptor_ids.append(calc_descriptor_id(
permanent_id, replica, self.timestamp, self.descriptor_cookie
))
self.assertTrue(b32decode(self.descriptor_id, 1) in descriptor_ids)
def test_parse_encrypted_introduction_points(self):
# Had to avoided setting the descriptor cookie for the basic auth
# example so that descriptor ID calculation would be correct.
descriptor_cookie = self.descriptor_cookie or self.intro_point_key
self.assertTrue(parse_encrypted_introduction_points(
self.introduction_points, descriptor_cookie
).startswith('introduction-point '))
class TestBasicAuthDescriptorCalculations(TestDescriptorCalculations,
unittest.TestCase):
def setUp(self):
self.permanent_key = RSA.importKey('\n'.join([
"-----BEGIN RSA PRIVATE KEY-----",
"MIICXQIBAAKBgQDRwt22Ua5RRZUrttrtfMARueyabyIYJSRPrtHLTME58OosiSaq",
"sYFOlEdYJ1yZaK0GAwuXh6DPJ2wSbuy71I2J6iDc/hJ/hZaWYU/nqi5NL/NyAeK9",
"BXegYMqWC8oBF1H4dG8B8GHxeJpL5emDgeiOhuYxwuOiNyx9eaq3R1uNBQIDAQAB",
"AoGAPT9o/ZNcEt7+b4U056NFceeX7oAEtIgj0iB5oaMHyKNPvTFO2Qh7eTZSnqrf",
"nuuxmc/J0rUHf3VDWR6KgU3PBa6oUkLZyshgXEysjtW4UYXAy4iHOtZ358yeQSLn",
"Ciz0DLqFVdwtOReYCKOIYlk+mmOdNQ67IyBDAe4Tcr0giAECQQDxIzVE09Jc7iyj",
"kAaj/RxWQDGlGZRvl82eZQVU4aMCO2QSHjkRGpgRsSdfIEQTb+uy4U/h0Yz9MYVV",
"sHYBzazpAkEA3rCWBbZcOFknNlp7G4BKj9Ky94ExmzBT+64oPoEFoKukB1Fr9KRj",
"nJ8UUXmLLqwsN3MAHTcWkoOAeU9vSLidvQJBAJx7TZkxoIS/5uXpk/WdTmNGWzEZ",
"rXLRXxTX16LinebX5bPAOyY3TNHGVZdsl+DJM3osrqsLUmQIW89kqN+4uekCQQCv",
"nWzCFn0FhvFQgOxi1Lp4T34Jh93595PTgBWGrTMl8RYLG1/abyWLJzzbv9FOPkMk",
"e1GUuJPZeVEA2e7113m1AkAQPWytigj7bp+jIoEgKBbPjT7BZP1X1ZfoaQEkR70c",
"RBOqte6jM17NeQQAlisIC9TWjKVFbYHkkc2n2priAb3g",
"-----END RSA PRIVATE KEY-----"
]))
self.introduction_points = b64decode(''.join([
"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"
]))
self.onion_address = 'xpe5atmz5d26k26e'
# Intro point key is the descriptor_cookie,
self.intro_point_key = b64decode('dCmx3qIvArbil8A0KM4KgQ==')
self.descriptor_cookie = None # Hack to not include cookie for desc ID
self.timestamp = 1424808000 # 2015-02-24 20:00:00
self.descriptor_id = 'yfmvdrkdbyquyqk5vygyeylgj2qmrvrd'
self.secret_id_part = 'fluw7z3s5cghuuirq3imh5jjj5ljips6'
class TestStealthAuthDescriptorCalculations(TestDescriptorCalculations,
unittest.TestCase):
def setUp(self):
# Use the client key for stealth authentication
self.permanent_key = RSA.importKey('\n'.join([
"-----BEGIN RSA PRIVATE KEY-----",
"MIICXAIBAAKBgQC9X+xnRDmx/sTIX+BrGkwrX1R+2aWp3oAuuY59Pys/cJZKRfkk",
"HSL07h2tomQd+Bg09j708vfHIUsc/hNyr9oeOaUFm68CzdxY+KmvpJXJSF1nCGux",
"k+D3LWGcwLxyIfbfRAHuno2LvCPoeMi+kDQANm2DotXeuvqgjQgBQYAKqQIDAQAB",
"AoGAC7Mudt7XNbEI1VxfEB7qz88u+DtYKduOTdS3AfPyJxQ8pNAX6WxHaZyAhua+",
"ir92N2dzUkzklA/xhRQJfY9xyUruu8aOrBW6UzZE76s2PQyClgU2jWUi3PVDjoag",
"7CrKkBM3/IPq34b6IEIffp68iOFIhStjQHBUpnMlHuBzrAUCQQDyl2JtQAoOK17x",
"ZePx8u/jSxnbT2b6vQQ9334KKrEagFmdfHx8uBktkjWgdM2nrX4/TUqgAke3Iz/v",
"CIN82mQTAkEAx9eI7KaRyts3EQ4vNJIDxZOglVVIR09xMFhXWfqYRE2PdthqG82E",
"JKAJMf9MYYd69XTqjdUud2lxMfkKeM4V0wJAUEAkH1//85AFaHX8Yh2rndVKSHKL",
"7oZ40L8OQu68h7fN7Xsw81Ezgw/LDbmWDtIl4WsANM6MStkuXTTDypm0YQJBAJx9",
"c4OdjF1F/IEmkmCgVsPJLt7Bwa/VzdUF2KFlUwdplQaDwdOzw97KU2kLekyFQwwj",
"WelnHtPzheiUFFc1SnECQClXuBVw/mIoac6mbuw83uhLwSip3Aid8NpDgqQKNQAP",
"pRDzVrFeiNNWcvTfiZyM4HvXAK2xD+XNSi8CtSu3zuQ=",
"-----END RSA PRIVATE KEY-----"
]))
self.introduction_points = b64decode(''.join([
"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"
]))
self.onion_address = 'tosbmbgysyldansp'
self.descriptor_cookie = b64decode('dCmx3qIvArbil8A0KM4KgR==')
self.timestamp = 1424808000 # 2015-02-24 20:00:00
self.descriptor_id = 'ubf3xeibzlfil6s4larq6y5peup2z3oj'
self.secret_id_part = 'jczvydhzetbpdiylj3d5nsnjvaigs7xm'
if __name__ == '__main__':
unittest.main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment