From c53d8e15499005c07cb6d423d0d8b57fd08ae840 Mon Sep 17 00:00:00 2001 From: Konstantin Ryabitsev Date: Wed, 17 Aug 2022 16:25:21 -0400 Subject: ez: initial rework of web submission endpoint Reimplement initial enrolment with the web submission endpoint. A lot more work is required before this is useful, but we're at least able to authenticate received messages. Signed-off-by: Konstantin Ryabitsev --- misc/send-receive.py | 141 +++++++++++++++++++++++++++++++++------------------ misc/test.sqlite | Bin 24576 -> 0 bytes 2 files changed, 93 insertions(+), 48 deletions(-) delete mode 100644 misc/test.sqlite (limited to 'misc') diff --git a/misc/send-receive.py b/misc/send-receive.py index 7b47798..c102508 100644 --- a/misc/send-receive.py +++ b/misc/send-receive.py @@ -5,10 +5,7 @@ import os import logging import json import sqlalchemy as sa - -from nacl.signing import VerifyKey -from nacl.encoding import Base64Encoder -from nacl.exceptions import BadSignatureError +import patatt DB_VERSION = 1 @@ -34,19 +31,13 @@ class SendReceiveListener(object): auth = sa.Table('auth', md, sa.Column('auth_id', sa.Integer(), primary_key=True), sa.Column('created', sa.DateTime(), nullable=False, server_default=sa.sql.func.now()), - sa.Column('email', sa.Text(), nullable=False), - sa.Column('name', sa.Text(), nullable=False), + sa.Column('identity', sa.Text(), nullable=False), + sa.Column('selector', sa.Text(), nullable=False), sa.Column('pubkey', sa.Text(), nullable=False), + sa.Column('challenge', sa.Text(), nullable=True), + sa.Column('verified', sa.Integer(), nullable=False), ) - sa.Index('idx_email_pubkey', auth.c.pubkey, auth.c.email, unique=True) - challenge = sa.Table('challenge', md, - sa.Column('challenge_id', sa.Integer(), primary_key=True), - sa.Column('created', sa.DateTime(), nullable=False, server_default=sa.sql.func.now()), - sa.Column('pubkey', sa.Text(), nullable=False), - sa.Column('email', sa.Text(), nullable=False), - sa.Column('challenge', sa.Text(), nullable=False), - ) - sa.Index('idx_uniq_challenge', challenge.c.pubkey, challenge.c.email, challenge.c.challenge, unique=True) + sa.Index('idx_identity_selector', auth.c.identity, auth.c.selector, unique=True) md.create_all(self._engine) q = sa.insert(meta).values(version=DB_VERSION) conn.execute(q) @@ -68,56 +59,107 @@ class SendReceiveListener(object): # Is it already authorized? conn = self._engine.connect() md = sa.MetaData() + identity = jdata.get('identity') + selector = jdata.get('selector') + pubkey = jdata.get('pubkey') t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine) - email = jdata.get('email') - pubkey = jdata.get('key') - q = sa.select([t_auth.c.auth_id]).where(t_auth.c.email == email, t_auth.c.pubkey == pubkey) + q = sa.select([t_auth.c.auth_id]).where(t_auth.c.identity == identity, t_auth.c.selector == selector, + t_auth.c.verified == 1) rp = conn.execute(q) if len(rp.fetchall()): - self.send_error(resp, message='%s:%s is already authorized' % (email, pubkey)) + self.send_error(resp, message='i=%s;s=%s is already authorized' % (identity, selector)) return # delete any existing challenges for this and create a new one - t_challenge = sa.Table('challenge', md, autoload=True, autoload_with=self._engine) - q = sa.delete(t_challenge).where(t_challenge.c.email == email, t_challenge.c.pubkey == pubkey) + q = sa.delete(t_auth).where(t_auth.c.identity == identity, t_auth.c.selector == selector, + t_auth.c.verified == 0) conn.execute(q) # create new challenge import uuid cstr = str(uuid.uuid4()) - q = sa.insert(t_challenge).values(pubkey=pubkey, email=email, challenge=cstr) + q = sa.insert(t_auth).values(identity=identity, selector=selector, pubkey=pubkey, challenge=cstr, + verified=0) conn.execute(q) # TODO: Actual mail sending logger.info('Challenge: %s', cstr) self.send_success(resp, message='Challenge generated') + def validate_message(self, conn, t_auth, bdata, verified=1): + # Returns auth_id of the matching record + pm = patatt.PatattMessage(bdata) + if not pm.signed: + return None + auth_id = identity = pubkey = None + for ds in pm.get_sigs(): + selector = 'default' + identity = '' + i = ds.get_field('i') + if i: + identity = i.decode() + s = ds.get_field('s') + if s: + selector = s.decode() + logger.debug('i=%s; s=%s', identity, selector) + q = sa.select([t_auth.c.auth_id, t_auth.c.pubkey]).where(t_auth.c.identity == identity, + t_auth.c.selector == selector, + t_auth.c.verified == verified) + rp = conn.execute(q) + res = rp.fetchall() + if res: + auth_id, pubkey = res[0] + break + + logger.debug('auth_id=%s', auth_id) + if not auth_id: + return None + try: + pm.validate(identity, pubkey.encode()) + except Exception as ex: + logger.debug('Validation failed: %s', ex) + return None + + return auth_id + def auth_verify(self, jdata, resp): - # Do we have a record for this email/challenge? + msg = jdata.get('msg') + if msg.find('\nverify:') < 0: + self.send_error(resp, message='Invalid verification message') + return conn = self._engine.connect() md = sa.MetaData() - t_challenge = sa.Table('challenge', md, autoload=True, autoload_with=self._engine) - email = jdata.get('email', '') - challenge = jdata.get('challenge', '') - sigdata = jdata.get('sigdata', '') - q = sa.select([t_challenge.c.pubkey]).where(t_challenge.c.email == email, t_challenge.c.challenge == challenge) + t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine) + bdata = msg.encode() + auth_id = self.validate_message(conn, t_auth, bdata, verified=0) + if auth_id is None: + self.send_error(resp, message='Signature validation failed') + return + # Now compare the challenge to what we received + q = sa.select([t_auth.c.challenge]).where(t_auth.c.auth_id == auth_id) rp = conn.execute(q) - qres = rp.fetchall() - if not len(qres): - self.send_error(resp, message='No such challenge for %s' % email) + res = rp.fetchall() + challenge = res[0][0] + if msg.find(f'\nverify:{challenge}') < 0: + self.send_error(resp, message='Invalid verification string') return - pubkey = qres[0][0] - vk = VerifyKey(pubkey.encode(), encoder=Base64Encoder) - try: - vk.verify(sigdata.encode(), encoder=Base64Encoder) - except BadSignatureError: - self.send_error(resp, message='Could not validate signature for %s' % email) + q = sa.update(t_auth).where(t_auth.c.auth_id == auth_id).values(challenge=None, verified=1) + conn.execute(q) + self.send_success(resp, message='Challenge verified') + + def auth_delete(self, jdata, resp): + msg = jdata.get('msg') + if msg.find('\nauth-delete') < 0: + self.send_error(resp, message='Invalid key delete message') return - # validated at this point, so record this as valid auth - name = jdata.get('name') + conn = self._engine.connect() + md = sa.MetaData() t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine) - q = sa.insert(t_auth).values(pubkey=pubkey, name=name, email=email) - conn.execute(q) - q = sa.delete(t_challenge).where(t_challenge.c.email == email, t_challenge.c.challenge == challenge) + bdata = msg.encode() + auth_id = self.validate_message(conn, t_auth, bdata) + if auth_id is None: + self.send_error(resp, message='Signature validation failed') + return + q = sa.delete(t_auth).where(t_auth.c.auth_id == auth_id) conn.execute(q) - self.send_success(resp, message='Challenge verified') + self.send_success(resp, message='Authentication deleted') def on_post(self, req, resp): if not req.content_length: @@ -133,17 +175,20 @@ class SendReceiveListener(object): resp.content_type = falcon.MEDIA_TEXT resp.text = 'Failed to parse the request\n' return - logger.info(jdata) action = jdata.get('action') if action == 'auth-new': self.auth_new(jdata, resp) + return if action == 'auth-verify': self.auth_verify(jdata, resp) - else: - resp.status = falcon.HTTP_500 - resp.content_type = falcon.MEDIA_TEXT - resp.text = 'Unknown action: %s\n' % action return + if action == 'auth-delete': + self.auth_delete(jdata, resp) + return + + resp.status = falcon.HTTP_500 + resp.content_type = falcon.MEDIA_TEXT + resp.text = 'Unknown action: %s\n' % action app = falcon.App() diff --git a/misc/test.sqlite b/misc/test.sqlite deleted file mode 100644 index fc7a99b..0000000 Binary files a/misc/test.sqlite and /dev/null differ -- cgit v1.2.3