Skip to content
Snippets Groups Projects
fingerprint_change_checker.py 6.58 KiB
Newer Older
#!/usr/bin/env python
# Copyright 2015-2019, Damian Johnson and The Tor Project
# See LICENSE for licensing information

"""
Simple script that checks to see if relays rapidly change their finterprint.
This can indicate malicious intent toward hidden services.
"""

import datetime
import time
import traceback

import util

from stem.descriptor.remote import DescriptorDownloader
from stem.util import datetime_to_unix, conf

EMAIL_SUBJECT = 'Relays Changing Fingerprint'

EMAIL_BODY = """\
The following relays are frequently changing their fingerprints...

"""

FINGERPRINT_CHANGES_FILE = util.get_path('data', 'fingerprint_changes')
ONE_DAY = 24 * 60 * 60
TEN_DAYS = 10 * 24 * 60 * 60

log = util.get_logger('fingerprint_change_checker')


def main():
  last_notified_config = conf.get_config('last_notified')
  last_notified_path = util.get_path('data', 'fingerprint_change_last_notified.cfg')

  if os.path.exists(last_notified_path):
    last_notified_config.load(last_notified_path)
  else:
    last_notified_config._path = last_notified_path

  fingerprint_changes = load_fingerprint_changes()
  downloader = DescriptorDownloader(timeout = 15)

  for relay in downloader.get_consensus():
    prior_fingerprints = fingerprint_changes.setdefault((relay.address, relay.or_port), {})

    if relay.fingerprint not in prior_fingerprints:
      log.debug("Registering a new fingerprint for %s:%s (%s)" % (relay.address, relay.or_port, relay.fingerprint))
      prior_fingerprints[relay.fingerprint] = datetime_to_unix(relay.published)

      # drop fingerprint changes that are over thirty days old

      old_fingerprints = [fp for fp in prior_fingerprints if (time.time() - prior_fingerprints[fp] > TEN_DAYS)]

      for fp in old_fingerprints:
        log.debug("Removing fingerprint for %s:%s (%s) which was published %i days ago" % (relay.address, relay.or_port, fp, prior_fingerprints[fp] / 60 / 60 / 24))
        del prior_fingerprints[fp]
      # if we've changed more than ten times in the last ten days then alarm
      if len(prior_fingerprints) >= 10:
        alarm_for['%s:%s' % (relay.address, relay.or_port)] = (relay.address, relay.or_port, relay.fingerprint)
  if alarm_for and not is_notification_suppressed(alarm_for.values()):
    log.debug("Sending a notification for %i relays..." % len(alarm_for))
    body = EMAIL_BODY

    for address, or_port, fingerprint in alarm_for.values():
      try:
        desc = downloader.get_server_descriptors(fingerprint).run()[0]
      except:
        desc = None  # might not be available, just used for extra info

      fp_changes = fingerprint_changes[(address, or_port)]
      log.debug("* %s:%s has had %i fingerprints: %s" % (address, or_port, len(fp_changes), ', '.join(fp_changes.keys())))

      if desc:
        body += "* %s:%s (platform: %s, contact: %s)\n" % (address, or_port, desc.platform.decode('utf-8', 'replace'), desc.contact)
      else:
        body += "* %s:%s\n" % (address, or_port)

      for fingerprint in sorted(fp_changes, reverse = True, key = lambda k: fp_changes[k]):
        body += "  %s at %s\n" % (fingerprint, datetime.datetime.fromtimestamp(fp_changes[fingerprint]).strftime('%Y-%m-%d %H:%M:%S'))
        count += 1

        # Relays frequently cycling their fringerprint can have thousands of
        # entries. Enumerating them all is unimportant, so if too long then
        # just give the count.

        if count > 8:
          oldest_timestamp = sorted(fp_changes.values())[0]
          body += "  ... and %i more since %s\n" % (len(fp_changes) - 8, datetime.datetime.fromtimestamp(oldest_timestamp).strftime('%Y-%m-%d %H:%M:%S'))
          break
    subject = EMAIL_SUBJECT

    if len(alarm_for) == 1:
      subject += ' (%s:%s)' % alarm_for.values()[0][:2]
    util.send(subject, body = body, to = ['tor-network-alerts@lists.torproject.org', 'atagar@torproject.org'])
    # register that we've notified for these

    current_time = str(int(time.time()))

    for address, or_port, _ in alarm_for.values():
      last_notified_config.set('%s:%s' % (address, or_port), current_time)

    last_notified_config.save()

  save_fingerprint_changes(fingerprint_changes)


def load_fingerprint_changes():
  """
  Loads information about prior fingerprint changes we've persisted. This
  provides a dictionary of the form...

    (address, or_port) => {fingerprint: published_timestamp...}
  """

  log.debug("Loading fingerprint changes...")
  config = conf.get_config('fingerprint_changes')

  try:
    config.load(FINGERPRINT_CHANGES_FILE)
    fingerprint_changes = {}

    for key in config.keys():
      address, or_port = key.split(':', 1)

      for value in config.get(key, []):
        fingerprint, published = value.split(':', 1)
        fingerprint_changes.setdefault((address, int(or_port)), {})[fingerprint] = float(published)

    log.debug("  information for %i relays found" % len(fingerprint_changes))
    return fingerprint_changes
  except IOError as exc:
    log.debug("  unable to read '%s': %s" % (FINGERPRINT_CHANGES_FILE, exc))
    return {}


def save_fingerprint_changes(fingerprint_changes):
  log.debug("Saving fingerprint changes for %i relays" % len(fingerprint_changes))
  config = conf.get_config('fingerprint_changes')
  config.clear()

  for address, or_port in fingerprint_changes:
    for fingerprint, published in fingerprint_changes[(address, or_port)].items():
      config.set('%s:%s' % (address, or_port), '%s:%s' % (fingerprint, published), overwrite = False)

  try:
    config.save(FINGERPRINT_CHANGES_FILE)
  except IOError as exc:
    log.debug("  unable to save '%s': %s" % (FINGERPRINT_CHANGES_FILE, exc))


def is_notification_suppressed(fingerprint_changes):
  """
  Check to see if we've already notified for all these endpoints today. No
  point in causing too much noise.
  """

  is_all_suppressed = True
  log.debug("Checking if notification should be suppressed...")
  last_notified_config = conf.get_config('last_notified')

  for address, or_port, _ in fingerprint_changes:
    key = '%s:%s' % (address, or_port)
    suppression_time = ONE_DAY - (int(time.time()) - last_notified_config.get(key, 0))

    if suppression_time < 0:
      log.debug("* notification for %s isn't suppressed" % key)
      is_all_suppressed = False
    else:
      log.debug("* we already notified for %s recently, suppressed for %i hours" % (key, suppression_time / 3600))

  return is_all_suppressed


if __name__ == '__main__':
  try:
    main()
  except:
    msg = "fingerprint_change_checker.py failed with:\n\n%s" % traceback.format_exc()
    log.error(msg)
    util.send("Script Error", body = msg, to = [util.ERROR_ADDRESS])