Skip to content
Permalink
0ed2d3e43f
Switch branches/tags
Go to file
 
 
Cannot retrieve contributors at this time
486 lines (404 sloc) 18.7 KB
from __future__ import print_function
from StringIO import StringIO
import json
import platform
import os
import re
import time
import subprocess
import shutil
import tempfile
import functools
import pkg_resources
import OpenSSL
import zope.interface
from twisted.python import usage, log
from twisted.plugin import IPlugin
from twisted.internet import reactor, defer, ssl, endpoints, error
from twisted.internet._sslverify import PublicKey
from twisted.internet.protocol import Protocol
from twisted.web.client import Agent, ProxyAgent, RedirectAgent, ResponseDone, ResponseFailed
from twisted.web.http_headers import Headers
from carml.interface import ICarmlCommand
from carml import util
DEBUG = False
# TODO:
#
# stream isolation: we should ask for a Tor connection, add a
# SOCKSPort to it for our own use, and connect to that (and then
# delete it from config when done). Unless there's an easier way.
class DownloadBundleOptions(usage.Options):
optFlags = [
('beta', 'b', 'Use the beta release (if available).'),
('alpha', 'a', 'Use the alpha release (if available).'),
('use-clearnet', '', 'Do the download over plain Internet, NOT via Tor (NOT RECOMMENDED).'),
('system-keychain', 'K', 'Instead of creating a temporary keychain with provided Tor keys, '
"use the current user\'s existing GnuPG keychain."),
('no-extract', 'E', 'Do not extract after downloading.'),
]
optParameters = []
def __init__(self):
super(DownloadBundleOptions, self).__init__()
self.longOpt.remove('version')
# FIXME
# Twisted 14.0.0 can "just do" chain verification, I believe
# at *least* verify this does a similar thing, or just depend on >=14
# and delete it
class VerifyCertChainContextFactory(ssl.ClientContextFactory):
def __init__(self, cert_chain):
'''
:param cert_chain:
A list containing ``twisted.internet.ssl.Certificate``
instances, with the "depth 0" one being first. Create
these with ``twisted.internet.ssl.Certificate.loadPEM()``
'''
self.chain = {} # key=int (depth), value=certificate
for (i, cert) in enumerate(cert_chain):
self.chain[i] = cert
if DEBUG:
for i in range(len(self.chain)):
print('%d: %s' % (i, self.chain[i].getSubject()))
def getContext(self, host, port):
ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
# load just the DigiCert root as "the" certificate store
# WARNING although it's tempting to just call
# set_default_verify_paths(), on OS X that fails. And
# apparently if you fail to have a certificate store at all,
# OpenSSL fails to actually-verify anything, even with all
# the asking that follows here AND if you just "return 0"
# from the verify_hostname callback...so don't do that.
# FIXME there is something we could do here, maybe? can we
# confirm that the cert store is in such a state that OpenSSL
# will actually do verifications??!
store = ctx.get_cert_store()
store.add_cert(OpenSSL.crypto.load_certificate(OpenSSL.SSL.FILETYPE_PEM,
pkg_resources.resource_string('carml', 'keys/digicert-root-ca.pem')))
# FAIL_IF_NO_PEER_CERT is requireCertification in Twisted
# VERIFY_CLIENT_ONCE is verifyOnce in Twisted
ctx.set_verify(OpenSSL.SSL.VERIFY_PEER | OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT | OpenSSL.SSL.VERIFY_CLIENT_ONCE,
self.verify_hostname)
# verifyDepth from Twisted
ctx.set_verify_depth(len(self.chain))
# fixBrokenPeer from Twisted
if False:
_OP_ALL = getattr(OpenSSL.SSL, 'OP_ALL', 0x0000FFFF)
ctx.set_options(_OP_ALL)
# enableSingleUseKeys in Twisted
ctx.set_options(OpenSSL.SSL.OP_SINGLE_DH_USE)
# enableSessions in Twisted
# see http://twistedmatrix.com/trac/browser/tags/releases/twisted-13.2.0/twisted/internet/_sslverify.py#L800
# name = "%s-%d" % (reflect.qual(self.__class__), _sessionCounter())
# sessionName = md5(networkString(name)).hexdigest()
# _OP_NO_TICKET = 0x00004000
# could disallow session tickets with ctx.set_options(self._OP_NO_TICKET)
# if enableSessions in Twisted, calls ctx.set_session_id(md5(string + counter))
return ctx
def verify_hostname(self, connection, cert, errno, depth, preverifyOK):
if DEBUG:
print('Verify: pre=%d depth=%d cert=%s issuer=%s' % (preverifyOK,
depth,
cert.get_subject(),
cert.get_issuer()))
if not preverifyOK:
# FIXME if below is true about constant-time, then this
# shouldn't bail out early either...
return False
if depth >= len(self.chain):
print("depth is %d, but we have a chain %d entries long." % (depth, len(self.chain)))
return False
# FIXME TODO requires thinking
# need to ensure this is constant-time? e.g. compare hashes?
# or are we already screwed on that since we'll bail out of
# this whole "verify" callback stack early when one cert
# fails?
verify_pubkey = PublicKey(cert.get_pubkey())
golden_pubkey = self.chain[depth].getPublicKey()
if DEBUG:
print('incoming="%s", golden="%s"' % (verify_pubkey.keyHash(), golden_pubkey.keyHash()))
if not golden_pubkey.matches(verify_pubkey):
# getting out the CN (common name) for a nicer output,
# but maybe we don't want that -- let "experts" examine
# "failed.pem" and everyone else just needs to know "it
# didn't work"?
cn = ''
for (k, v) in cert.get_subject().get_components():
if k == 'CN':
cn = v
common_name = ''.join(map(lambda x: str(x[1]), filter(lambda x: x[0] == 'CN', cert.get_subject().get_components())))
print('Certificate chain verification failed for "%s".' % common_name)
print('Public key md5 hash is "%s" but wanted "%s".' % (verify_pubkey.keyHash(), golden_pubkey.keyHash()))
print('Dumping failing certificate to "failed.pem".')
with open('failed.pem', 'a') as f:
f.write(OpenSSL.crypto.dump_certificate(OpenSSL.SSL.FILETYPE_PEM, cert))
return False
# already checked preverifyOK
return True
class ResponseReceiver(Protocol):
def __init__(self, content, total, all_done):
self.content = content
self.total = int(total)
self.current = 0
self.last_output = None
self.start = time.time() # FIXME use IReactorTime to get this!
self.all_done = all_done
def dataReceived(self, data):
self.content.write(data)
self.update_progress(len(data))
def update_progress(self, datalen):
self.current += datalen
if self.total > 1024 and self.current:
howmuch = int((self.current * 100) / self.total)
if self.last_output is None or howmuch - self.last_output >= 10:
self.last_output = howmuch
elapsed = time.time() - self.start
velocity = self.current / elapsed
cur = self.current
tot = self.total
for (order, unit) in [(1024, 'KiB'), (1024 * 1024, 'MiB'), (1024 * 1024 * 1024, 'GiB')]:
if tot / order < 100:
break
cur /= order
tot /= order
units = unit
estimate = self.total / velocity
left = estimate - elapsed
print('%2d%%: %.1f of %.1f %s (%ds remaining)' % (howmuch, cur, tot, units, left))
if self.current == self.total:
print('%0.2f MiB/s' % (self.total / (1024.0 * 1024) / elapsed))
def connectionLost(self, reason):
self.all_done.callback(str(reason))
@defer.inlineCallbacks
def download(agent, uri, filelike):
resp = yield agent.request('GET', uri)
while resp.code == 302:
newloc = resp.headers.getRawHeaders('location')[0]
print("Following 302:", newloc)
resp = yield agent.request('GET', newloc)
if resp.code != 200:
raise RuntimeError('Failed to download "%s": %d' % (uri, resp.code))
done = defer.Deferred()
total = resp.length
dl = ResponseReceiver(filelike, total, done)
resp.deliverBody(dl)
yield done
def get_download_urls(plat, arch, target_version):
if '64' in arch:
arch = 64
else:
arch = 32
sig_fname = dict(linux='tor-browser-linux%d-%s_en-US.tar.xz.asc' % (arch, target_version),
darwin='TorBrowserBundle-%s-osx32_en-US.zip.asc' % (target_version, ),
windows='torbrowser-install-%s_en-US.exe.asc' % (target_version, ),
)[plat]
dist_fname = sig_fname[:-4]
return (sig_fname, dist_fname)
def extraction_instructions(fname):
print("To extract:")
print(" 7z x %s" % fname)
print(" tar xf %s" % fname[:-3])
print("or just:")
print(' 7z -bd -so e "%s" | tar xf -' % fname)
def extract_7zip(fname):
import backports.lzma as lzma
import tarfile
lz = lzma.open(str(fname))
print('Extracting "%s"...' % fname)
print(' decompressing...')
tar = tarfile.open(fileobj=lz)
def progress_generator(tar):
prog = 0
so_far = 0
total = len(tar.getmembers())
last = 0.0
for ti in tar:
so_far += 1
percent = int((float(so_far) / float(total)) * 100.0)
if last is None or percent - last >= (100.0 / 5.0):
last = percent
print(' %3d%% extracted' % percent)
yield ti
tar.extractall(members=progress_generator(tar))
return None
class DownloadBundleCommand(object):
zope.interface.implements(ICarmlCommand, IPlugin)
name = 'downloadbundle'
help_text = """Download the lastest Tor Browser Bundle (with pinned SSL certificates) and check the signatures."""
controller_connection = False
build_state = False
options_class = DownloadBundleOptions
def validate(self, options, mainoptions):
if not options['no-extract']: # and 'win' in platform.platform()[0].lower():
try:
import backports.lzma
except ImportError:
msg = 'You need "backports.lzma" installed to do 7zip extraction.'
raise RuntimeError(msg)
@defer.inlineCallbacks
def run(self, options, mainoptions, connection):
# NOTE the middle cert changed on April 10 or thereabouts;
# still need to confirm this is legitimate?
chain = [ssl.Certificate.loadPEM(pkg_resources.resource_string('carml', 'keys/torproject.pem')),
ssl.Certificate.loadPEM(pkg_resources.resource_string('carml', 'keys/digicert-sha2.pem')),
ssl.Certificate.loadPEM(pkg_resources.resource_string('carml', 'keys/digicert-root-ca.pem')),
]
cf = VerifyCertChainContextFactory(chain)
error_wrapper = None
if options['use-clearnet']:
print(util.colors.red('WARNING') + ': downloading over plain Internet (not via Tor).')
agent = Agent(reactor, contextFactory=cf)
else:
try:
import txsocksx.http
conn = "tcp:127.0.0.1:9050"
tor_ep = endpoints.clientFromString(reactor, conn)
agent = txsocksx.http.SOCKS5Agent(reactor,
proxyEndpoint=tor_ep,
contextFactory=cf)
def nicer_error(fail):
if fail.trap(error.ConnectError):
m = fail.getErrorMessage()
raise RuntimeError("Couldn't contact Tor on SOCKS5 (via \"%s\"): %s" % (conn, m))
return fail
error_wrapper = nicer_error
except ImportError:
raise RuntimeError('You need "txsocksx" installed to download via Tor.')
uri = 'https://www.torproject.org/projects/torbrowser/RecommendedTBBVersions'
data = StringIO()
print('Getting recommended versions from "%s".' % uri)
d = download(agent, uri, data)
def ssl_errors(fail):
if hasattr(fail.value, 'reasons'):
msg = ''
for r in fail.value.reasons:
msg += str(r.value.args[-1])
raise RuntimeError(msg)
return fail
d.addErrback(ssl_errors)
if error_wrapper is not None:
d.addErrback(error_wrapper)
yield d
# valid platforms from check.torproject.org can be one of:
# 'Linux', 'MacOS' or 'Windows'
plat = platform.system().lower()
arch = platform.uname()[-2]
plat_to_tor = dict(linux='Linux', darwin='MacOS', windows='Win')
if plat not in plat_to_tor:
print('Unknown platform "%s".' % plat)
raise RuntimeError('Unknown platform "%s".' % plat)
tor_plat = plat_to_tor[plat]
try:
versions = json.loads(data.getvalue())
except:
print('Error getting versions; invalid JSON:')
print(data.getvalue())
raise RuntimeError('Invalid JSON:\n%s' % data.getvalue())
alpha_re = re.compile(r'[0-9]*.[0-9]*a[0-9]-(Windows|MacOS|Linux)')
beta_re = re.compile(r'[0-9]*.[0-9]*b[0-9]-(Windows|MacOS|Linux)')
print(util.wrap(', '.join(versions), 60, ' '))
alphas = filter(lambda x: alpha_re.match(x), versions)
betas = filter(lambda x: beta_re.match(x), versions)
others = set(versions).difference(alphas, betas)
if options['alpha']:
versions = alphas
elif options['beta']:
versions = betas
else:
versions = others
if alphas:
print("Note: there are alpha versions available; use --alpha to download.")
if betas:
print("Note: there are beta versions available; use --beta to download.")
target_version = None
for v in versions:
if v.endswith(tor_plat):
target_version = v[:v.rfind('-')]
if target_version is None:
print("Can't find a version to download")
print(" My platform is: %s (%s)" % (plat, plat_to_tor[plat]))
print(" Potential versions are: %s" % ', '.join(versions))
if options['beta']:
print("(Try without --beta)")
elif options['alpha']:
print("(Try without --alpha)")
raise RuntimeError("Nothing to download found.")
# download the signature, then browser-bundle (if they don't
# already exist locally).
sig_fname, dist_fname = get_download_urls(plat, arch, target_version)
for to_download in [sig_fname, dist_fname]:
uri = bytes('https://www.torproject.org/dist/torbrowser/%s/%s' % (target_version, to_download))
if os.path.exists(to_download):
print(util.colors.red(to_download) + ': already exists, so not downloading.')
else:
def cleanup(failure, fname):
print('removing "%s"...' % fname)
os.unlink(fname)
return failure
f = open(to_download, 'w')
print('Downloading "%s".' % to_download)
d = download(agent, uri, f)
d.addErrback(cleanup, to_download)
yield d
f.close()
# ensure the signature matches
if verify_signature(sig_fname, system_gpg=bool(options['system-keychain'])):
print(util.colors.green("Signature is good."))
if options['no-extract']:
print("Download and signature check of the Tor Browser Bundle")
print("has SUCCEEDED.\n")
print("It is here: %s\n" % os.path.realpath(dist_fname))
extraction_instructions(dist_fname)
print("and then:")
else:
try:
extract_7zip(dist_fname)
print("Tor Browser Bundle downloaded and extracted.")
except ImportError:
msg = 'You need "backports.lzma" installed to do 7zip extraction.'
print(util.colors.red('Error: ') + msg, isError=True)
extraction_instructions(dist_fname)
print("To run:")
# running instructions
lang = dist_fname[-12:-7]
print(" ./tor-browser_%s/start-tor-browser" % lang)
else:
print(util.colors.bold('Deleting tarball; signature verification failed.'))
os.unlink(dist_fname)
print('...however signature file is being kept for reference (%s).' % sig_fname)
def verify_signature(fname, system_gpg=False):
verify_command = ['gpg', '--quiet']
td = None
status = False # pessimism!
try:
if not system_gpg:
# create temporary homedir
td = tempfile.mkdtemp()
verify_command.extend(['--homedir', td])
# add Tor project-people keys to it (the ones who
# sign releases, anyway)
keys = []
keys_path = os.path.join(td, 'keys')
os.mkdir(keys_path)
for k in pkg_resources.resource_listdir('carml', 'keys'):
if k.endswith('.asc'):
keys.append(pkg_resources.resource_filename('carml', os.path.join('keys', k)))
if len(keys) == 0:
raise RuntimeError('Internal error: failed to find shipped keys.')
try:
if subprocess.check_call(['gpg', '--quiet', '--homedir', td, '--import'] + keys):
raise RuntimeError("Key import failed.")
except IOError:
raise RuntimeError("GPG verification failed; is GnuPG installed?")
verify_command.extend(['--verify', fname])
try:
subprocess.check_call(verify_command)
status = True
except subprocess.CalledProcessError:
print(util.colors.bold(util.colors.red("Signature verification failed.")))
status = False
finally:
if td:
shutil.rmtree(td)
return status
# the IPlugin/getPlugin stuff from Twisted picks up any object from
# here than implements ICarmlCommand -- so we need to instantiate one
cmd = DownloadBundleCommand()