diff --git a/Pipfile b/Pipfile index 26bc494..4f6df24 100644 --- a/Pipfile +++ b/Pipfile @@ -5,10 +5,10 @@ name = "pypi" [packages] aiohttp = "*" -asyncio-irc = "*" pycrypto = "*" simplejson = "*" pyyaml = "*" +blinker = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 802abe4..63740bc 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "18bbb3d2a3de6c69514d095176966458dd6b98b1d4834cee00c67aba2af5af8f" + "sha256": "7d43dd71bd5f6ab7a5e47465e091d518de798e3bc2f94b576243a38613253a4e" }, "pipfile-spec": 6, "requires": { @@ -45,13 +45,6 @@ "markers": "python_version >= '3.5.3'", "version": "==3.0.0" }, - "asyncio-irc": { - "hashes": [ - "sha256:65b5b74c1795ea1be61e68cfa67fd59019abafca86cd2ba5684da04573554dbc" - ], - "index": "pypi", - "version": "==0.2.1" - }, "attrs": { "hashes": [ "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", @@ -63,6 +56,7 @@ "hashes": [ "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" ], + "index": "pypi", "version": "==1.4" }, "chardet": { diff --git a/viera/__main__.py b/viera/__main__.py index 3b2caea..2e2eebf 100644 --- a/viera/__main__.py +++ b/viera/__main__.py @@ -3,6 +3,7 @@ import aiohttp.web import logging from . import app +from .irc import irc_bot async def start_webserver(): @@ -18,6 +19,7 @@ async def start_webserver(): def main(): loop = asyncio.get_event_loop() asyncio.ensure_future(start_webserver()) + asyncio.ensure_future(irc_bot()) loop.run_forever() diff --git a/viera/actor.py b/viera/actor.py index 1ba32e8..42193e8 100644 --- a/viera/actor.py +++ b/viera/actor.py @@ -1,6 +1,11 @@ import aiohttp import aiohttp.web import logging +import uuid +import urllib.parse +import simplejson as json +import re +import cgi from Crypto.PublicKey import RSA from .database import DATABASE @@ -18,7 +23,13 @@ if "actorKeys" not in DATABASE: } +PRIVKEY = RSA.importKey(DATABASE["actorKeys"]["privateKey"]) +PUBKEY = PRIVKEY.publickey() + + from . import app +from .remote_actor import fetch_actor + async def actor(request): @@ -44,3 +55,79 @@ async def actor(request): app.router.add_get('/actor', actor) + + +from .http_signatures import sign_headers + + +async def push_message_to_actor(actor, message, our_key_id): + url = urllib.parse.urlsplit(actor['inbox']) + + # XXX: Digest + data = json.dumps(message) + headers = { + '(request-target)': 'post {}'.format(url.path), + 'Content-Length': str(len(data)), + 'Content-Type': 'application/activity+json', + 'User-Agent': 'Viera' + } + headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id) + + async with aiohttp.ClientSession() as session: + async with session.post(actor['inbox'], data=data, headers=headers) as resp: + pass + + +tag_re = re.compile(r'(|<[^>]*>)') +def strip_html(data): + no_tags = tag_re.sub('', data) + return cgi.escape(no_tags) + + +from .authreqs import check_reqs + + +async def handle_create(actor, data, request): + content = strip_html(data['object']['content']).split() + check_reqs(content, actor) + + +async def handle_follow(actor, data, request): + message = { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Accept", + "to": [actor["id"]], + + # this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile. + "object": { + "type": "Follow", + "id": data["id"], + "object": "https://{}/actor".format(request.host), + "actor": actor["id"] + }, + + "id": "https://{}/activities/{}".format(request.host, uuid.uuid4()), + } + await push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host)) + + +processors = { + 'Create': handle_create, + 'Follow': handle_follow +} + + +async def inbox(request): + data = await request.json() + + actor = await fetch_actor(data["actor"]) + actor_uri = 'https://{}/actor'.format(request.host) + + processor = processors.get(data['type'], None) + if processor: + await processor(actor, data, request) + + return aiohttp.web.Response(body=b'{}', content_type='application/activity+json') + + +app.router.add_post('/inbox', inbox) diff --git a/viera/authreqs.py b/viera/authreqs.py new file mode 100644 index 0000000..049dd93 --- /dev/null +++ b/viera/authreqs.py @@ -0,0 +1,59 @@ +import uuid +import logging +from collections import namedtuple + + +from .database import DATABASE + + +PendingAuth = namedtuple('PendingAuth', ['irc_nickname', 'irc_account', 'actor']) + + +AUTHS = DATABASE.get('auths', {}) +DATABASE["auths"] = AUTHS +PENDING_AUTHS = {} +IRC_BOT = None + + +def check_reqs(chunks, actor): + global DATABASE + + results = [x in PENDING_AUTHS for x in chunks] + logging.debug('AUTHREQ: chunks: %r, results: %r', chunks, results) + + if True in results: + pending_slot = results.index(True) + pending_uuid = chunks[pending_slot] + req = PENDING_AUTHS.pop(pending_uuid)._replace(actor=actor["id"]) + + logging.debug("IRC BOT: %r, AUTHREQ: %r", IRC_BOT, req) + + if IRC_BOT: + IRC_BOT.handle_auth_req(req) + + DATABASE["auths"][req.irc_account] = req.actor + + +def new_auth_req(irc_nickname, irc_account): + authid = str(uuid.uuid4()) + PENDING_AUTHS[authid] = PendingAuth(irc_nickname, irc_account, None) + + return authid + + +def set_irc_bot(bot): + global IRC_BOT + + IRC_BOT = bot + logging.debug("SET IRC BOT TO: %r", bot) + + +def check_auth(account): + return account in DATABASE["auths"] + + +def fetch_auth(account): + if check_auth(account): + return DATABASE["auths"][account] + + return None diff --git a/viera/irc.py b/viera/irc.py new file mode 100644 index 0000000..4f5584c --- /dev/null +++ b/viera/irc.py @@ -0,0 +1,217 @@ +import asyncio +import logging +import base64 + +from blinker import signal + +from . import CONFIG +from .irc_envelope import RFC1459Message + +from .authreqs import new_auth_req, set_irc_bot, check_auth, fetch_auth +IRC_CONFIG = CONFIG.get('irc', {}) + +# SASL_PAYLOAD = base64.b64encode(b'\x00'.join([IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_password']])) + + +class IRCProtocol(asyncio.Protocol): + def connection_made(self, transport): + self.pending_actions = {} + + self.caps_available = [] + self.caps_requested = [] + self.caps_acknowledged = [] + + self.transport = transport + self.recv_buffer = b'' + + self.send_message(verb='CAP', params=['LS']) + self.send_message(verb='NICK', params=[IRC_CONFIG['nickname']]) + self.send_message(verb='USER', params=[IRC_CONFIG['username'], '*', '*', IRC_CONFIG['realname']]) + + def data_received(self, data): + self.recv_buffer += data + recvd = self.recv_buffer.replace(b'\r', b'').split(b'\n') + self.recv_buffer = recvd.pop(-1) + + [self.line_received(m) for m in recvd] + + def line_received(self, data): + data = data.decode('UTF-8', 'replace').strip('\r\n') + + m = RFC1459Message.from_message(data) + self.message_received(m) + + def request_caps(self, caps): + caps = [cap for cap in caps if cap in self.caps_available] + caps = [cap for cap in caps if cap not in self.caps_requested] + + logging.debug('IRC: requesting caps: %r', caps) + + self.caps_requested += caps + self.send_message(verb='CAP', params=['REQ', ' '.join(caps)]) + + def end_caps(self): + self.send_message(verb='CAP', params=['END']) + + def do_blind_authenticate(self, username, password): + username = username.encode('ascii') + password = password.encode('ascii') + + payload = b'\x00'.join([username, username, password]) + payload = base64.b64encode(payload).decode('ascii') + + self.send_message(verb='AUTHENTICATE', params=['PLAIN']) + self.send_message(verb='AUTHENTICATE', params=[payload]) + + def handle_cap_message(self, message): + self.cap_requested = True + if message.params[1] == 'LS': + caps = message.params[2].split() + logging.debug('IRC: available caps: %r', caps) + + self.caps_available += message.params[2].split() + self.request_caps(['sasl', 'extended-join', 'account-tag', 'account-notify']) + elif message.params[1] == 'ACK': + caps = message.params[2].split() + logging.debug('IRC: acknowledged caps: %r', caps) + + self.caps_acknowledged += caps + if 'sasl' in self.caps_acknowledged: + self.do_blind_authenticate(IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_password']) + else: + self.end_caps() + + def join_channels(self): + self.send_message(verb='JOIN', params=[','.join(IRC_CONFIG['channels'])]) + + def say(self, target, message, verb='NOTICE'): + self.send_message(verb=verb, params=[target, message]) + + def invite(self, nickname): + [self.send_message(verb="INVITE", params=[nickname, chan]) for chan in IRC_CONFIG['channels']] + + def voice(self, nickname): + [self.send_message(verb="MODE", params=[chan, "+v", nickname]) for chan in IRC_CONFIG['channels']] + + def whois(self, nickname, chan, account): + data = fetch_auth(account) + if not data: + return + self.say(chan, '\x02{0}\x02: \x02{1}\x02'.format(nickname, data), verb='PRIVMSG') + + def set_pending_action(self, nickname, action): + if nickname not in self.pending_actions: + self.pending_actions[nickname] = action + + def process_pending_action(self, nickname, account=None): + if nickname not in self.pending_actions: + return + action = self.pending_actions.pop(nickname) + if action == 'voice': + self.voice(nickname) + elif action == 'invite': + self.invite(nickname) + elif 'whois' in action: + self.whois(nickname, action['whois'], account) + + def handle_auth_req(self, req): + self.say(req.irc_nickname, "The actor \x02{0}\x02 is now linked to the IRC account \x02{1}\x02.".format(req.actor, req.irc_account)) + self.set_pending_action(req.irc_nickname, 'voice') + self.process_pending_action(req.irc_nickname) + + def pending_whois(self, nickname, pop=False): + if nickname not in self.pending_actions: + return False + data = self.pending_actions[nickname] + if isinstance(data, dict) and 'whois' in data: + return True + if pop: + self.pending_actions.pop(nickname) + + def handle_whox(self, message): + nickname = message.params[1] + account = message.params[2] + + if not check_auth(account) and not self.pending_whois(nickname, True): + auth = new_auth_req(nickname, account) + self.say(nickname, "Authentication is required for this action. In order to prove your identity, you need to send me a token via the fediverse.") + self.say(nickname, "On most platforms, posting like this will work: \x02@viera@viera.dereferenced.org {}\x02".format(auth)) + self.say(nickname, "This token is ephemeral, so you can send it to me publicly if your platform does not support direct messages.") + else: + self.process_pending_action(nickname, account) + + def fetch_account_whox(self, message): + source_nick = message.source.split('!')[0] + self.send_message(verb='WHO', params=[source_nick, "%na"]) + + def handle_private_message(self, message): + source_nick = message.source.split('!')[0] + if message.params[1] == 'auth': + self.fetch_account_whox(message) + elif message.params[1] in ('voice', 'invite'): + self.set_pending_action(source_nick, message.params[1]) + self.fetch_account_whox(message) + + def handle_public_message(self, message): + if not message.params[1].startswith(IRC_CONFIG['nickname']): + return + + chunks = message.params[1].split() + if chunks[1] == 'whois': + self.set_pending_action(chunks[2], {'whois': message.params[0]}) + self.send_message(verb='WHO', params=[chunks[2], "%na"]) + + def handle_chat_message(self, message): + if message.params[0] == IRC_CONFIG['nickname']: + self.handle_private_message(message) + else: + self.handle_public_message(message) + + def handle_join(self, message): + source_nick = message.source.split('!')[0] + if check_auth(message.params[1]): + self.set_pending_action(source_nick, 'voice') + self.process_pending_action(source_nick, message.params[1]) + + def message_received(self, message): + if message.verb in ('PRIVMSG', 'NOTICE'): + self.handle_chat_message(message) + elif message.verb == '001': + self.join_channels() + elif message.verb == 'JOIN': + self.handle_join(message) + elif message.verb == 'CAP': + self.handle_cap_message(message) + elif message.verb == '354': + self.handle_whox(message) + elif message.verb == '433': + self.send_message(verb='NICK', params=[message.params[0] + '_']) + elif message.verb in ('900', '901', '902', '903', '904', '905', '906', '907'): + self.end_caps() + elif message.verb == 'PING': + self.send_message(verb='PONG', params=message.params) + elif message.verb in ('AUTHENTICATE',): + pass + else: + logging.debug('IRC: unhandled inbound message: %r', message) + + def send_message(self, **kwargs): + m = RFC1459Message.from_data(**kwargs) + logging.debug('> %r', m) + self.transport.write(m.to_message().encode('utf-8') + b'\r\n') + + +async def irc_bot(): + loop = asyncio.get_event_loop() + + if 'host' not in IRC_CONFIG: + return + + server = IRC_CONFIG['host'] + port = IRC_CONFIG['port'] + ssl = IRC_CONFIG['ssl'] + + transport, protocol = await loop.create_connection(IRCProtocol, host=server, port=port, ssl=ssl) + set_irc_bot(protocol) + + logging.info('IRC bot ready.') diff --git a/viera/irc_envelope.py b/viera/irc_envelope.py new file mode 100644 index 0000000..e980eeb --- /dev/null +++ b/viera/irc_envelope.py @@ -0,0 +1,146 @@ +# irc_envelope.py +# Purpose: Conversion of RFC1459 messages to/from native objects. +# +# Copyright (c) 2014, William Pitcock +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from pprint import pprint + +class RFC1459Message(object): + @classmethod + def from_data(cls, verb, params=None, source=None, tags=None): + o = cls() + o.verb = verb + o.tags = dict() + o.source = None + o.params = list() + + if params: + o.params = params + + if source: + o.source = source + + if tags: + o.tags.update(**tags) + + return o + + @classmethod + def from_message(cls, message): + if isinstance(message, bytes): + message = message.decode('UTF-8', 'replace') + + s = message.split(' ') + + tags = None + if s[0].startswith('@'): + tag_str = s[0][1:].split(';') + s = s[1:] + tags = {} + + for tag in tag_str: + if '=' in tag: + k, v = tag.split('=', 1) + tags[k] = v + else: + tags[tag] = True + + source = None + if s[0].startswith(':'): + source = s[0][1:] + s = s[1:] + + verb = s[0].upper() + original_params = s[1:] + params = [] + + while len(original_params): + # skip multiple spaces in middle of message, as per 1459 + if original_params[0] == '' and len(original_params) > 1: + original_params.pop(0) + continue + elif original_params[0].startswith(':'): + arg = ' '.join(original_params)[1:] + params.append(arg) + break + else: + params.append(original_params.pop(0)) + + return cls.from_data(verb, params, source, tags) + + def args_to_message(self): + base = [] + for arg in self.params: + casted = str(arg) + if casted and ' ' not in casted and casted[0] != ':': + base.append(casted) + else: + base.append(':' + casted) + break + + return ' '.join(base) + + def to_message(self): + components = [] + + if self.tags: + components.append('@' + ';'.join([k + '=' + v for k, v in self.tags.items()])) + + if self.source: + components.append(':' + self.source) + + components.append(self.verb) + + if self.params: + components.append(self.args_to_message()) + + return ' '.join(components) + + def to_event(self): + return "rfc1459 message " + self.verb, self.__dict__ + + def serialize(self): + return self.__dict__ + + def __repr__(self): + return '[RFC1459Message: "{0}"]'.format(self.to_message()) + +def test_rfc1459message(): + print('====== PARSER TESTS ======') + print(RFC1459Message.from_message('@foo=bar PRIVMSG kaniini :this is a test message!')) + print(RFC1459Message.from_message('@foo=bar :irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!')) + print(RFC1459Message.from_message('PRIVMSG kaniini :this is a test message!')) + print(RFC1459Message.from_message(':irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!')) + print(RFC1459Message.from_message('CAPAB ')) + + print('====== STRUCTURE TESTS ======') + m = RFC1459Message.from_message('@foo=bar;bar=baz :irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!') + pprint(m.serialize()) + + print('====== BUILDER TESTS ======') + data = { + 'verb': 'PRIVMSG', + 'params': ['kaniini', 'hello world!'], + 'source': 'kaniini!~kaniini@localhost', + 'tags': {'account-name': 'kaniini'}, + } + m = RFC1459Message.from_data(**data) + print(m.to_message()) + pprint(m.serialize()) + + print('====== ALL TESTS: PASSED ======') + +if __name__ == '__main__': + test_rfc1459message()