mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-04-29 10:10:19 +00:00
697 lines
22 KiB
Python
697 lines
22 KiB
Python
#! /usr/bin/python -*- mode: python; mode: auto-fill; fill-column: 80; -*-
|
|
|
|
"""A simple Santiago service.
|
|
|
|
Start me with:
|
|
|
|
$ python -i simple_santiago.py
|
|
|
|
This will provide you with a running Santiago service. The important tags in
|
|
this file are:
|
|
|
|
- query
|
|
- request
|
|
- index
|
|
- handle_request
|
|
- handle_reply
|
|
|
|
They operate, essentially, in that order. The first Santiago service queries
|
|
another's index with a request. That request is handled and a request is
|
|
returned. Then, the reply is handled. The upshot is that we learn a new set of
|
|
locations for the service.
|
|
|
|
This is currently incomplete. We don't sign, encrypt, verify, or decrypt
|
|
request messages. I wanted to get the functional fundamentals in place first.
|
|
|
|
We also don't:
|
|
|
|
- Proxy requests.
|
|
- Use a reasonable data-store.
|
|
- Have a decent control mechanism.
|
|
|
|
:FIXME: add that whole pgp thing.
|
|
:TODO: add doctests
|
|
:TODO: Create startup script that adds all necessary things to the PYTHONPATH.
|
|
:FIXME: allow multiple listeners and senders per protocol (with different
|
|
proxies)
|
|
:TODO: move to santiago.py, merge the documentation.
|
|
|
|
"""
|
|
|
|
import cfg
|
|
from collections import defaultdict as DefaultDict
|
|
import gnupg
|
|
import logging
|
|
import sys
|
|
|
|
|
|
def load_data(server, item):
|
|
"""Return evaluated file contents.
|
|
|
|
FIXME: use withsqlite instead.
|
|
|
|
"""
|
|
with open("%s_%s" % (server, item)) as infile:
|
|
return eval(infile.read())
|
|
|
|
|
|
class SimpleSantiago(object):
|
|
"""This Santiago is a less extensible Santiago.
|
|
|
|
The client and server are unified, and it has hardcoded support for
|
|
protocols.
|
|
|
|
"""
|
|
def __init__(self, listeners, senders, hosting, consuming, me):
|
|
"""Create a Santiago with the specified parameters.
|
|
|
|
listeners and senders are both protocol-specific dictionaries containing
|
|
relevant settings per protocol:
|
|
|
|
{ "http": { "port": 80 } }
|
|
|
|
hosting and consuming are service dictionaries, one being an inversion
|
|
of the other. hosting contains services you host, while consuming lists
|
|
services you use, as a client.
|
|
|
|
hosting: { "someKey": { "someService": ( "http://a.list",
|
|
"http://of.locations" )}}
|
|
|
|
consuming: { "someService": { "someKey": ( "http://a.list",
|
|
"http://of.locations" )}}
|
|
|
|
Messages are delivered by defining both the source and destination
|
|
("from" and "to", respectively). Separating this from the hosting and
|
|
consuming allows users to safely proxy requests for one another, if some
|
|
hosts are unreachable from some points.
|
|
|
|
"""
|
|
self.hosting = hosting
|
|
self.consuming = consuming
|
|
self.requests = DefaultDict(set)
|
|
self.me = me
|
|
self.gpg = gnupg.GPG(use_agent = True)
|
|
|
|
self.listeners = self._create_connectors(listeners, "Listener")
|
|
self.senders = self._create_connectors(senders, "Sender")
|
|
|
|
def _create_connectors(self, settings, connector):
|
|
"""Iterates through each protocol given, creating connectors for all.
|
|
|
|
This assumes that the caller correctly passes parameters for each
|
|
connector. If not, we log a TypeError and continue to serve any
|
|
connectors we can create successfully. If other types of errors occur,
|
|
we quit.
|
|
|
|
"""
|
|
connectors = dict()
|
|
|
|
for protocol in settings.iterkeys():
|
|
module = SimpleSantiago._get_protocol_module(protocol)
|
|
|
|
try:
|
|
connectors[protocol] = \
|
|
getattr(module, connector)(self, **settings[protocol])
|
|
|
|
# log a type error, assume all others are fatal.
|
|
except TypeError:
|
|
logging.error("Could not create %s %s with %s",
|
|
protocol, connector, str(settings[protocol]))
|
|
|
|
return connectors
|
|
|
|
@classmethod
|
|
def _get_protocol_module(cls, protocol):
|
|
"""Return the requested protocol module.
|
|
|
|
FIXME: Assumes the current directory is in sys.path
|
|
|
|
"""
|
|
import_name = "protocols." + protocol
|
|
|
|
if not import_name in sys.modules:
|
|
__import__(import_name)
|
|
|
|
return sys.modules[import_name]
|
|
|
|
def start(self):
|
|
"""Start all listeners and senders attached to this Santiago.
|
|
|
|
When this has finished, the Santiago will be ready to go.
|
|
|
|
"""
|
|
for connector in list(self.listeners.itervalues()) + \
|
|
list(self.senders.itervalues()):
|
|
connector.start()
|
|
|
|
logging.debug("Santiago started!")
|
|
|
|
def am_i(self, server):
|
|
"""Verify whether this server is the specified server."""
|
|
|
|
return self.me == server
|
|
|
|
def learn_service(self, host, service, locations):
|
|
"""Learn a service somebody else hosts for me."""
|
|
|
|
if locations:
|
|
self.consuming[service][host].union(locations)
|
|
|
|
def provide_service(self, client, service, locations):
|
|
"""Start hosting a service for somebody else."""
|
|
|
|
if locations:
|
|
self.hosting[client][service].union(locations)
|
|
|
|
def get_host_locations(self, client, service):
|
|
"""Return where I'm hosting the service for the client.
|
|
|
|
Return nothing if the client or service are unrecognized.
|
|
|
|
"""
|
|
try:
|
|
return self.hosting[client][service]
|
|
except KeyError as e:
|
|
logging.exception(e)
|
|
|
|
def get_client_locations(self, host, service):
|
|
"""Return where the host serves the service for me, the client."""
|
|
|
|
try:
|
|
return self.consuming[service][host]
|
|
except KeyError as e:
|
|
logging.exception(e)
|
|
|
|
def query(self, host, service):
|
|
"""Request a service from another Santiago.
|
|
|
|
This tag starts the entire Santiago request process.
|
|
|
|
"""
|
|
try:
|
|
self.requests[host].add(service)
|
|
|
|
self.outgoing_request(
|
|
host, self.me, host, self.me,
|
|
service, None, self.get_client_locations(host, "santiago"))
|
|
except Exception as e:
|
|
logging.exception("Couldn't handle %s.%s", host, service)
|
|
|
|
def outgoing_request(self, from_, to, host, client,
|
|
service, locations, reply_to):
|
|
"""Send a request to another Santiago service.
|
|
|
|
This tag is used when sending queries or replies to other Santiagi.
|
|
|
|
"""
|
|
# FIXME sign the encrypted payload.
|
|
# FIXME move it out of here so proxying can work.
|
|
payload = self.gpg.encrypt(
|
|
{"host": host, "client": client,
|
|
"service": service, "locations": locations or "",
|
|
"reply_to": reply_to}, to, sign=self.me)
|
|
request = self.gpg.sign({"request": payload, "to": to})
|
|
|
|
for destination in self.get_client_locations(to, "santiago"):
|
|
protocol = destination.split(":")[0]
|
|
self.senders[protocol].outgoing_request(request, destination)
|
|
|
|
def incoming_request(self, **kwargs):
|
|
"""Provide a service to a client.
|
|
|
|
This tag doesn't do any real processing, it just catches and hides
|
|
errors from the sender, so that every request is met with silence.
|
|
|
|
The only data an attacker should be able to pull from a client is:
|
|
|
|
- The fact that a server exists and is serving HTTP 200s.
|
|
- The round-trip time for that response.
|
|
- Whether the server is up or down.
|
|
|
|
Worst case scenario, a client causes the Python interpreter to
|
|
segfault and the Santiago process comes down, while the system
|
|
is set up to reject connections by default. Then, the
|
|
attacker knows that the last request brought down a system.
|
|
|
|
"""
|
|
logging.debug("Incoming request: ", str(kwargs))
|
|
|
|
# no matter what happens, the sender will never hear about it.
|
|
try:
|
|
try:
|
|
request = self.unpack_request(kwargs)
|
|
except ValueError as e:
|
|
self.proxy(kwargs)
|
|
return
|
|
|
|
logging.debug("Unpacked request: ", str(request))
|
|
|
|
if request["locations"]:
|
|
self.handle_reply(request["from"], request["to"],
|
|
request["host"], request["client"],
|
|
request["service"], request["locations"],
|
|
request["reply_to"])
|
|
else:
|
|
self.handle_request(request["from"], request["to"],
|
|
request["host"], request["client"],
|
|
request["service"], request["reply_to"])
|
|
except Exception as e:
|
|
logging.exception("Error: ", str(e))
|
|
|
|
def unpack_request(self, kwargs):
|
|
"""Decrypt and verify the request.
|
|
|
|
Raise an (unhandled) error if there're any inconsistencies in the
|
|
message.
|
|
|
|
The message is wrapped in up to three ways:
|
|
|
|
1. The outermost signature: This layer is applied to the message by the
|
|
message's sender. This allows for proxying signed messages between
|
|
clients.
|
|
|
|
2. The inner signature: This layer is applied to the message by the
|
|
original sender (the requesting client or replying host). The
|
|
message's destination is recorded in plain-text in this layer so
|
|
proxiers can deliver the message.
|
|
|
|
3. The encrypted message: This layer is used by the host and client to
|
|
coordinate the service, hidden from prying eyes.
|
|
|
|
Yes, each host and client requires two verifications and one decryption
|
|
per message. Each proxier requires two verifications: the inner
|
|
signature must be valid, not necessarily trusted. The host and client
|
|
are the only folks who must trust the inner signature. Proxiers must
|
|
only verify that signature.
|
|
|
|
XXX: if we duplicate any keys in the signed message (for addressing)
|
|
they could (should?) be overwritten by the contents of the
|
|
encrypted message.
|
|
|
|
TODO: Do we use "to" or "host" the plain-text inner signature? If we
|
|
use "host", we'll need to use "client" on the way back, but that
|
|
feels like it gives up far too much information.
|
|
|
|
"""
|
|
request = kwargs["request"]
|
|
|
|
request = verify_sender(request)
|
|
request = verify_client(request)
|
|
|
|
if not self.am_i(request["host"]):
|
|
self.proxy(request)
|
|
return
|
|
|
|
request = decrypt_client(request)
|
|
|
|
return request
|
|
|
|
def verify_sender(self, request):
|
|
"""Verify the signature of the message's sender.
|
|
|
|
TODO Raises an InvalidSignature error when the signature is incorrect.
|
|
|
|
TODO Raises an UntrustedClient error when the signer is not a client
|
|
authorized to send us Santiago messages.
|
|
|
|
TODO Returns the signed message's contents.
|
|
|
|
"""
|
|
return request
|
|
|
|
def verify_client(self, request):
|
|
"""Verify the signature of the message's source.
|
|
|
|
TODO Raises an InvalidSignature error when the signature is incorrect.
|
|
|
|
TODO Raises an UntrustedClient error when the signer is not a client
|
|
authorized to send us Santiago messages.
|
|
|
|
TODO Returns the signed message's contents.
|
|
|
|
"""
|
|
pass
|
|
|
|
def decrypt_client(self, request):
|
|
"""Decrypt the message and validates the encrypted signature.
|
|
|
|
TODO Raises an InvalidSignature error when the signature is incorrect.
|
|
|
|
TODO Raises an UntrustedClient error when the signer is not a client
|
|
authorized to send us Santiago messages.
|
|
|
|
TODO Returns the contents of the encrypted request.
|
|
|
|
pre::
|
|
|
|
self.me == request["host"]
|
|
|
|
post::
|
|
|
|
False not in map(("host", "client", "service", "locations",
|
|
"reply_to"), request.__haskey__)
|
|
|
|
"""
|
|
pass
|
|
|
|
@staticmethod
|
|
def signed_contents(request):
|
|
"""Return the contents of the signed message.
|
|
|
|
TODO: complete.
|
|
|
|
"""
|
|
if not request.readline() == "-----BEGIN PGP SIGNED MESSAGE-----":
|
|
return
|
|
|
|
# skip the blank line
|
|
# contents = the thingie.
|
|
# contents end at "-----BEGIN PGP SIGNATURE-----"
|
|
# message ends at "-----END PGP SIGNATURE-----"
|
|
|
|
def handle_request(self, from_, to, host, client,
|
|
service, reply_to):
|
|
"""Actually do the request processing.
|
|
|
|
#. Verify we're willing to host for both the client and proxy. If we
|
|
aren't, quit and return nothing.
|
|
|
|
#. Forward the request if it's not for me.
|
|
|
|
#. Learn new Santiagi if they were sent.
|
|
|
|
#. Reply to the client on the appropriate protocol.
|
|
|
|
"""
|
|
try:
|
|
self.hosting[from_]
|
|
self.hosting[client]
|
|
except KeyError as e:
|
|
return
|
|
|
|
if not self.am_i(host):
|
|
self.proxy(to, host, client, service, reply_to)
|
|
else:
|
|
self.learn_service(client, "santiago", reply_to)
|
|
|
|
self.outgoing_request(
|
|
self.me, client, self.me, client,
|
|
service, self.get_host_locations(client, service),
|
|
self.get_host_locations(client, "santiago"))
|
|
|
|
def proxy(self, request):
|
|
"""Pass off a request to another Santiago.
|
|
|
|
Attempt to contact the other Santiago and ask it to reply both to the
|
|
original host as well as me.
|
|
|
|
TODO: add tests.
|
|
TODO: create.
|
|
|
|
"""
|
|
pass
|
|
|
|
def handle_reply(self, from_, to, host, client,
|
|
service, locations, reply_to):
|
|
"""Process a reply from a Santiago service.
|
|
|
|
The last call in the chain that makes up the Santiago system, we now
|
|
take the reply from the other Santiago server and learn any new service
|
|
locations, if we've requested locations for that service.
|
|
|
|
"""
|
|
try:
|
|
self.consuming[service][from_]
|
|
self.consuming[service][host]
|
|
except KeyError as e:
|
|
return
|
|
|
|
if not self.am_i(to):
|
|
return
|
|
|
|
if not self.am_i(client):
|
|
self.proxy()
|
|
return
|
|
|
|
self.learn_service(host, "santiago", reply_to)
|
|
|
|
if service in self.requests[host]:
|
|
self.learn_service(host, service, locations)
|
|
self.requests[host].remove(service)
|
|
|
|
def save_server(self):
|
|
"""Save all operational data to files.
|
|
|
|
Save all files with the ``self.me`` prefix.
|
|
|
|
"""
|
|
for datum in ("hosting", "consuming"):
|
|
name = "%s_%s" % (self.me, datum)
|
|
|
|
try:
|
|
with open(name, "w") as output:
|
|
output.write(str(getattr(self, datum)))
|
|
except Exception as e:
|
|
logging.exception("Could not save %s as %s", datum, name)
|
|
|
|
class SantiagoConnector(object):
|
|
"""Generic Santiago connector superclass.
|
|
|
|
All types of connectors should inherit from this class. These are the
|
|
"controllers" in the MVC paradigm.
|
|
|
|
"""
|
|
def __init__(self, santiago):
|
|
self.santiago = santiago
|
|
|
|
def start(self):
|
|
"""Called when initialization is complete.
|
|
|
|
Cannot block.
|
|
|
|
"""
|
|
pass
|
|
|
|
class SantiagoListener(SantiagoConnector):
|
|
"""Generic Santiago Listener superclass.
|
|
|
|
This class contains one optional method, the request receiving method. This
|
|
method passes the request along to the Santiago host.
|
|
|
|
"""
|
|
def incoming_request(self, **kwargs):
|
|
self.santiago.incoming_request(**kwargs)
|
|
|
|
class SantiagoSender(SantiagoConnector):
|
|
"""Generic Santiago Sender superclass.
|
|
|
|
This class contains one required method, the request sending method. This
|
|
method sends a Santiago request via that protocol.
|
|
|
|
"""
|
|
def outgoing_request(self):
|
|
raise Exception(
|
|
"santiago.SantiagoSender.outgoing_request not implemented.")
|
|
|
|
class SignatureError(Exception):
|
|
pass
|
|
|
|
class InvalidSignatureError(SignatureError):
|
|
pass
|
|
|
|
class UntrustedClientError(SignatureError):
|
|
pass
|
|
|
|
class PgpUnwrapper(object):
|
|
"""Removes one layer of PGP message header and footer per iteration.
|
|
|
|
Good for singly- or multiply-wrapped messages.
|
|
|
|
FIXME: replace with a real library for this. Why doesn't gnupg do this?
|
|
|
|
After a single iteration, the original message is available in
|
|
``original_message`` while the message's contents are in
|
|
``str(PgpUnwrapper)``.
|
|
|
|
Sucessive iterations unwrap additional layers of the message. Good for
|
|
onion-signed or -encrypted messages.
|
|
|
|
"""
|
|
START, HEAD, BODY, FOOTER, END = "start", "header", "body", "footer", "end"
|
|
|
|
SIG, CRYPT = "sig", "crypt"
|
|
|
|
SIG_HEAD, SIG_BODY, SIG_FOOTER, SIG_END = (
|
|
"-----BEGIN PGP SIGNED MESSAGE-----",
|
|
"",
|
|
"-----BEGIN PGP SIGNATURE-----",
|
|
"-----END PGP SIGNATURE-----")
|
|
|
|
CRYPT_HEAD, CRYPT_END = ("-----BEGIN PGP MESSAGE-----",
|
|
"-----END PGP MESSAGE-----")
|
|
|
|
def __init__(self, message,
|
|
gnupg_new = None, gnupg_verify = None, gnupg_decrypt = None):
|
|
|
|
if gnupg_new == None:
|
|
gnupg_new = dict()
|
|
if gnupg_verify == None:
|
|
gnupg_verify = dict()
|
|
if gnupg_decrypt == None:
|
|
gnupg_decrypt = dict()
|
|
|
|
self.message = message
|
|
self.gnupg_new = gnupg_new
|
|
self.gnupg_verify = gnupg_verify
|
|
self.gnupg_decrypt = gnupg_decrypt
|
|
self.type = ""
|
|
|
|
self.gpg = gnupg.GPG(**self.gnupg_new)
|
|
self.reset_fields()
|
|
|
|
def reset_fields(self):
|
|
"""Removes all extracted data from the iterator.
|
|
|
|
This resets it to a new or clean state, ready for the next iteration.
|
|
|
|
"""
|
|
self.start = list()
|
|
self.header = list()
|
|
self.body = list()
|
|
self.footer = list()
|
|
self.end = list()
|
|
|
|
def __iter__(self):
|
|
return self
|
|
|
|
def next(self):
|
|
"""Remove one layer of PGP message wrapping.
|
|
|
|
Return the message's contents, and set self.body as the message's body.
|
|
Also, set the message's header and footer in self, respectively.
|
|
|
|
Raise an InvalidSignature Error if signature isn't valid.
|
|
|
|
This is a really simple state-machine: certain lines advance the state
|
|
of the machine, and until the machine is advanced again, all lines are
|
|
added to that part of the message. We ignore any part of the message
|
|
that comes before the opening stanza.
|
|
|
|
"""
|
|
point = PgpUnwrapper.START
|
|
type_ = ""
|
|
|
|
self.reset_fields()
|
|
|
|
for line in self.message.splitlines():
|
|
if point == PgpUnwrapper.START and line == PgpUnwrapper.SIG_HEAD:
|
|
point = PgpUnwrapper.HEAD
|
|
type_ = PgpUnwrapper.SIG
|
|
elif point == PgpUnwrapper.START and line == PgpUnwrapper.CRYPT_HEAD:
|
|
point = PgpUnwrapper.HEAD
|
|
type_ = PgpUnwrapper.CRYPT
|
|
elif point == PgpUnwrapper.HEAD and line == PgpUnwrapper.SIG_BODY:
|
|
point = PgpUnwrapper.BODY
|
|
elif (point == PgpUnwrapper.BODY and line == PgpUnwrapper.SIG_FOOTER and
|
|
type_ == PgpUnwrapper.SIG):
|
|
point = PgpUnwrapper.FOOTER
|
|
elif ((point == PgpUnwrapper.FOOTER and line == PgpUnwrapper.SIG_END and type_ == PgpUnwrapper.SIG) or
|
|
(point == PgpUnwrapper.BODY and line == PgpUnwrapper.CRYPT_END and type_ == PgpUnwrapper.CRYPT)):
|
|
self.footer.append(line)
|
|
point = PgpUnwrapper.END
|
|
continue
|
|
|
|
getattr(self, point).append(line)
|
|
|
|
self.handle_message(point, type_)
|
|
|
|
return "\n".join(self.body)
|
|
|
|
def handle_message(self, point, type_):
|
|
"""Handle end-conditions of message.
|
|
|
|
Do the right thing based on the state machine's results.
|
|
|
|
"""
|
|
if point != PgpUnwrapper.END or type_ not in (PgpUnwrapper.CRYPT,
|
|
PgpUnwrapper.SIG):
|
|
raise StopIteration("No valid PGP data.")
|
|
|
|
args = (self.gnupg_verify if type_ == PgpUnwrapper.SIG
|
|
else self.gnupg_decrypt)
|
|
|
|
data = { PgpUnwrapper.SIG: self.gpg.verify,
|
|
PgpUnwrapper.CRYPT: self.gpg.decrypt}[type_](str(self), **args)
|
|
|
|
self.body = PgpUnwrapper.unwrap(self.body)
|
|
self.type = type_
|
|
|
|
if not data:
|
|
raise InvalidSignatureError()
|
|
|
|
# reset the state machine, now that we've unwrapped a layer.
|
|
self.message = "\n".join(self.body)
|
|
|
|
@classmethod
|
|
def unwrap(cls, message):
|
|
lines = (PgpUnwrapper.SIG_HEAD, PgpUnwrapper.SIG_FOOTER,
|
|
PgpUnwrapper.SIG_END,
|
|
PgpUnwrapper.CRYPT_HEAD, PgpUnwrapper.CRYPT_END)
|
|
|
|
for line in message:
|
|
if True in map(str.endswith, [line] * len(lines), lines):
|
|
message[message.index(line)] = line[2:]
|
|
|
|
return message
|
|
|
|
def __str__(self):
|
|
"""Returns the GPG-part of the current message.
|
|
|
|
Non-PGP-message data are not returned.
|
|
|
|
"""
|
|
return "\n".join([
|
|
"\n".join(x) for x in (self.header, self.body, self.footer) ])
|
|
|
|
def original_message(self):
|
|
"""Returns the current wrapped message.
|
|
|
|
It's an iterator, so it discards previous iterations' data.
|
|
|
|
"""
|
|
return "\n".join([
|
|
"\n".join(x) for x in (self.start, self.header, self.body,
|
|
self.footer, self.end) ])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# FIXME: convert this to the withsqlite setup.
|
|
|
|
cert = "santiago.crt"
|
|
listeners = { "https": { "socket_port": 8080,
|
|
"ssl_certificate": cert,
|
|
"ssl_private_key": cert }, }
|
|
senders = { "https": { "proxy_host": "localhost",
|
|
"proxy_port": 8118} }
|
|
mykey = "D95C32042EE54FFDB25EC3489F2733F40928D23A"
|
|
# mykey = "0928D23A" # my short key
|
|
|
|
# load hosting
|
|
try:
|
|
hosting = load_data(mykey, "hosting")
|
|
except IOError:
|
|
hosting = { "a": { "santiago": set( ["https://localhost:8080"] )},
|
|
mykey: { "santiago": set( ["https://localhost:8080"] )}}
|
|
# load consuming
|
|
try:
|
|
consuming = load_data(mykey, "consuming")
|
|
except IOError:
|
|
consuming = { "santiago": { mykey: set( ["https://localhost:8080"] ),
|
|
"a": set( ["someAddress.onion"] )}}
|
|
|
|
# load the Santiago
|
|
santiago_b = SimpleSantiago(listeners, senders,
|
|
hosting, consuming, mykey)
|
|
|
|
santiago_b.start()
|