commit d6ff82eab25f9ccc16f07c575aa479fbac983b20 Author: = Date: Wed Aug 14 11:52:59 2019 -0500 Initial commit diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..b5aaaf7 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,9 @@ +Wed, 14 Aug 2019 11:52:59 -0500 +Keaton +Initial commit + +Project is still completely unworkable and unfinished, just starting to initialize things. + +-------------------- + + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c5c9855 --- /dev/null +++ b/readme.md @@ -0,0 +1,9 @@ +# SQRL for Django + +This is a reboot of [Miki725's django-sqrl](https://github.com/miki725/django-sqrl], +updated for Python 3.7 and Django 2.2. The vast majority of the code is unchanged, +so all credit for this working belongs with them. + +## Installation (eventually) + +It still isn't complete. This is placeholder. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1da7fc0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +ed25519 diff --git a/sqrl/__init__.py b/sqrl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sqrl/admin.py b/sqrl/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/sqrl/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/sqrl/apps.py b/sqrl/apps.py new file mode 100644 index 0000000..f26a68f --- /dev/null +++ b/sqrl/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SqrlConfig(AppConfig): + name = 'sqrl' diff --git a/sqrl/crypto.py b/sqrl/crypto.py new file mode 100644 index 0000000..7d66438 --- /dev/null +++ b/sqrl/crypto.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +from collections import OrderedDict +from os import urandom + +import ed25519 +from django.utils.crypto import constant_time_compare, salted_hmac + +from .utils import Base64, Encoder + + + +class HMAC(object): + """ + Utility class for generating and verifying HMAC signatures. + + This class relies on Django's built in :func:`salted_hmac` + to compute actual HMAC values by using ``SECRET_KEY`` as key. + + Parameters + ---------- + nut : SQRLNut + Nut from which necessary data is extracted to add a salt value + to the HMAC input data. + Currently only :attr:`.models.SQRLNut.session_key` is used. + data : OrderedDict + Dict for which to either compute or validate HMAC signature. + """ + + def __init__(self, nut, data): + self.nut = nut + self.data = data + + def sign_data(self): + """ + Generate HMAC signature for the provided data. + + Note + ---- + ``max`` key is ignored in the input data if that key is present. + + Returns + ------- + bytes + Binary signature of the data + """ + assert isinstance(self.data, OrderedDict) + + encoded = Encoder.base64_dumps(OrderedDict( + (k, v) for k, v in self.data.items() + if k != 'mac' + )) + signature = salted_hmac(self.nut.session_key, encoded).digest() + + return signature + + def is_signature_valid(self, other_signature): + """ + Check if the ``other_signature`` is a valid signature for the + provided data and the nut. + + Returns + ------- + bool + Boolean indicating whether validation has succeeded. + """ + expected_signature = self.sign_data() + return constant_time_compare(expected_signature, other_signature) + + +class Ed25519(object): + """ + Utility class for signing and verifying ed25519 signatures. + + More information about ed25519 can be found at ``_. + + Parameters + ---------- + public_key : bytes + Key used for verifying signature. + private_key : bytes + Key used for signing data. + msg : bytes + Binary data for which to generate the signature. + """ + + def __init__(self, public_key, private_key, msg): + self.public_key = public_key + self.private_key = private_key + self.msg = msg + + def is_signature_valid(self, other_signature): + """ + Check if ``other_signature`` is a valid signature for the provided message. + + Returns + ------- + bool + Boolean indicating whether validation has succeeded. + """ + try: + vk = ed25519.VerifyingKey(self.public_key) + vk.verify(other_signature, self.msg) + return True + except (AssertionError, ed25519.BadSignatureError): + return False + + def sign_data(self): + """ + Generate ed25519 signature for the provided data. + + Returns + ------- + bytes + ed25519 signature + """ + sk = ed25519.SigningKey(self.private_key) + return sk.sign(self.msg) + + +def generate_randomness(size=32): + """ + Generate random sample of specified size ``size``. + + Parameters + ---------- + size : int, optional + Number of bytes to generate random sample + + Returns + ------- + str + :meth:`.Base64.encode` encoded random sample + """ + return Base64.encode(urandom(size)) diff --git a/sqrl/managers.py b/sqrl/managers.py new file mode 100644 index 0000000..192aa17 --- /dev/null +++ b/sqrl/managers.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from django.db import models + + +class SQRLNutManager(models.Manager): + """ + Customer :obj:`.models.SQRLNut` model manager. + """ + + def replace_or_create(self, session_key, **kwargs): + """ + This method creates new :obj:`.models.SQRLNut` with given parameters. + + If nut already exists, it removes it before creating new nut. + + Parameters + ---------- + session_key : str + Key of the session. All nuts with matching session will be removed. + **kwargs + Kwargs which will be used to create new :obj:`.models.SQRLNut` + """ + self.get_queryset().filter(session_key=session_key).delete() + return self.create(session_key=session_key, **kwargs) diff --git a/sqrl/models.py b/sqrl/models.py new file mode 100644 index 0000000..035918a --- /dev/null +++ b/sqrl/models.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +from django.conf import settings +from django.db import models + +from .crypto import generate_randomness +from .managers import SQRLNutManager + + +class SQRLIdentity(models.Model): + """ + SQRL identity associated with a user. + + This model stores all necessary for SQRL to complete SQRL transactions + and return any data to the client when necessary. + + The reason this is a standalone model vs lets say extending Django User model + is because if added to the user model, each row for the user table is forced + to allocate space to store SQRL information which might not be desired. + By using a dedicated column, it allows to use SQRL only for users who use it. + Also this makes Django-SQRL more modular so that it is easier integrated with + existing projects. + + Attributes + ---------- + public_key : str + Base64 encoded public key of per-site user's SQRL public-private key pair. + This key is used to verify users signature signing SQRL transaction + including server generated random nut using users private key. + server_unlock_key : str + Base64 encoded server unlock key which is a public unlock key sent to client + which client can use to generate urs (unlock request signature) signature which + server can validate using vuk. + More information can be found at https://www.grc.com/sqrl/idlock.htm. + verify_unlock_key : str + Base64 encoded verify unlock key which is a key stored by server which is used + to validate urs (unlock request signature) signatures. This key is not sent to user. + More information can be found at https://www.grc.com/sqrl/idlock.htm. + is_enabled : bool + Boolean indicating whether user can authenticate using SQRL. + in_only_sqrl : bool + Boolean indicating that only SQRL should be allowed to authenticate user. + When enabled via flag in SQRL client requests, this should disable all other + methods of authentication such as username/password. + """ + user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='sqrl_identity', on_delete=models.CASCADE) + """Foreign key to Django's auth ``User`` object for whom this SQRL identity belongs to.""" + + public_key = models.CharField( + max_length=43, db_index=True, unique=True, + help_text='Public key of per-site users public-private key pair. ' + 'This key is used to verify users signature signing SQRL transaction ' + 'including server generated random nut using users private key.' + ) + server_unlock_key = models.CharField( + max_length=43, blank=True, + help_text='This is public unlock key sent to client which client can use to ' + 'generate urs (unlock request signature) signature which server can validate ' + 'using vuk.' + ) + verify_unlock_key = models.CharField( + max_length=43, blank=True, + help_text='This is a key stored by server which is used to validate ' + 'urs (unlock request signature) signatures. This key is not sent to user.' + ) + is_enabled = models.BooleanField( + default=True, + help_text='Boolean indicating whether user can authenticate using SQRL.' + ) + is_only_sqrl = models.BooleanField( + default=False, + help_text='Boolean indicating that only SQRL should be allowed to authenticate user. ' + 'When enabled via flag in SQRL client requests, this should disable all other ' + 'methods of authentication such as username/password.' + ) + + class Meta(object): + # Redefine db_table so that table name is not funny + # like sqrl_sqrlidentity. + # One way to solve that is to simply name the model + # Identity however that is generic enough which might cause + # name conflicts so its easier to rename the model + # and manually overwrite the table name + db_table = 'sqrl_identity' + verbose_name = 'SQRL Identity' + verbose_name_plural = 'SQRL Identities' + + def __str__(self): + if self.user_id: + return '{} for {}'.format(self.__class__.__name__, self.user) + else: + return '{} with idk={}'.format(self.__class__.__name__, self.public_key) + + +class SQRLNut(models.Model): + """ + Model for storing temporary state for SQRL transactions. + + This model by SQRL protocol is not strictly required. + Here is the reasoning for it though: + + SQRL protocol requires couple of things: + + 1. Each SQRL interaction must use random ``nonce`` or in SQRL terminology SQRL nut + 2. Strict enforcement that each nut can only be used at most once + 3. Each non-initiating SQRL request should return to the client some information + about the initiating request such as whether IP address matches that of + the IP where SQRL transaction was initiated. + + ``#3`` can easily be solved by encoding all necessary information in the nut value itself. + On subsequent requests, server can simply decode all the information from the nut. + That however will require nut value to grow pretty big. In addition this might also require + encryption so that nut value by itself is not revealing. That in turn requires some + rotating key management structure so that not all nuts are encrypted with same encryption key. + All of the above is possible however is not very elegant, but again, is still possible. + + In addition, encrypting state in nut value does not hinder ``#1`` since some random bit + can also be part of the nut so ``#1`` is not an issue. + + So far we dont need any state on the server. Each request can have its own nut value. + + The problem however is with ``#2`` requirement. The only way for the server to guarantee + that nuts are never reused is to either keep a state of which nuts were used or keep state + of all currently available nuts. Since keeping state of all used nuts will infinitely grow, + probably the latter option is better. The default way of adding state in Django project + would of been to add a model. But at that point, if you are creating a model anyway, + might as well store everything in that model. As a benefit, it makes whole system + simpler by not requiring any fancy approaches to encrypting nuts and maintaining + rotated key encryption schedule of some sort. + + Attributes + ---------- + nonce : str + Base64 encoded value of a random nonce. This nonce is used to identify SQRL transaction. + It is regenerated for each SQRL communication within a single SQRL transaction. + Since this nonce is a one-time token, it allows for the server to prevent replay attacks. + This columns is a primary key of the table. + transaction_nonce : str + Base64 encoded random nonce used to identify a full SQRL transaction from start to finish. + Session key cannot be used since it is persistent across complete user visit which can + include multiple tabs/windows. This transaction id is regenerated for each new tab which + allows the client to identify when a particular SQRL transaction has completed + hence redirect user to more appropriate page. + session_key : str + Session key of the session where SQRL transaction initiated which is used + to associate client session to SQRL transaction. This is important because SQRL + transaction can be completed on a different device which does not have access + to original user session. + is_transaction_complete : bool + Boolean indicating whether SQRL transaction has completed. It is used by the + :obj:`.views.SQRLStatusView` to return redirect URL to the user + when transaction has completed. + ip_address : str + IP address of the user who initiated SQRL transaction. This is where + initially SQRL link/qr code were generated. + timestamp : datetime + Last timestamp when nut was either created or modified. Used for puging purses + to remove expired nuts. + """ + nonce = models.CharField( + max_length=43, unique=True, db_index=True, primary_key=True, + help_text='Single-use random nonce used to identify SQRL transaction. ' + 'This nonce is regenerated for each SQRL communication within ' + 'a single SQRL transaction. Since this nonce is a one-time token, ' + 'it allows for the sviewerver to prevent replay attacks.' + ) + transaction_nonce = models.CharField( + max_length=43, unique=True, db_index=True, + help_text='A random nonce used to identify a full SQRL transaction. ' + 'Session key cannot be used since it is persistent across ' + 'complete user visit which can include multiple tabs/windows. ' + 'This transaction id is regenerated for each new tab which ' + 'allows the client to identify when a particular SQRL transaction ' + 'has completed hence redirect user to more appropriate page.' + ) + + session_key = models.CharField( + max_length=32, unique=True, db_index=True, + help_text='User regular session key. This is used to associate client session ' + 'to a SQRL transaction since transaction can be completed on a different ' + 'device which does not have access to original user session.' + ) + + is_transaction_complete = models.BooleanField( + default=False, + help_text='Indicator whether transaction is complete. ' + 'Can we used by UI to automatically redirect to appropriate page ' + 'once SQRL transaction is complete.', + ) + ip_address = models.GenericIPAddressField( + help_text='Originating IP address of client who initiated SQRL transaction. ' + 'Used to set appropriate TIF response code.', + ) + timestamp = models.DateTimeField( + auto_now=True, + help_text='Last timestamp when nut was either created or modified. ' + 'Used for purging purposes.', + ) + + objects = SQRLNutManager() + + class Meta(object): + # Explicitly define db_table for clearer table name + db_table = 'sqrl_nut' + verbose_name = 'SQRL Nut' + verbose_name_plural = 'SQRL Nuts' + + def __str__(self): + return self.nonce + + def renew(self): + """ + Renew instance of the nut. + + This is done by deleting the nut from the db since nonce is a primary key. + Then nonce is regenerated and re-saved. + """ + self.delete() + self.nonce = generate_randomness() + self.save() diff --git a/sqrl/sqrl.py b/sqrl/sqrl.py new file mode 100644 index 0000000..da432c2 --- /dev/null +++ b/sqrl/sqrl.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +from django.core.urlresolvers import reverse +from django.http import QueryDict + +from .crypto import generate_randomness +from .models import SQRLNut +from .utils import get_user_ip + + +class SQRLInitialization(object): + """ + SQRL class for initializing SQRL transaction. + + This class is mainly responsible for initially creating and storing + :obj:`.models.SQRLNut`. Also this class has helper properties + for getting SQRL urls. + + Parameters + ---------- + request : HttpRequest + Django standard request object + nut : SQRLNut, optional + SQRLNut for which to do SQRL initialization + """ + + def __init__(self, request, nut=None): + self.request = request + if nut is not None: + self.nut = nut + + def get_or_create_session_key(self): + """ + Get or create the session key from the request object. + + When not present yet, this initializes the session for the user. + As a result, the request then returns session cookie to the user + via session middleware. + """ + session_key = self.request.session.session_key + + if session_key is None: + self.request.session.create() + session_key = self.request.session.session_key + + return session_key + + @property + def nut(self): + """ + Cached property for getting :obj:`.models.SQRLNut`. + + When accessed for the first time, this property either replaces or creates + new :obj:`.models.SQRLNut` by using :meth:`.managers.SQRLNutManager.replace_or_create`. + All the data for the creation of the nut is created by using :meth:`.generate_nut_kwargs`. + """ + if hasattr(self, '_nut'): + return self._nut + + self._nut = SQRLNut.objects.replace_or_create( + **self.generate_nut_kwargs() + ) + + return self._nut + + @nut.setter + def nut(self, value): + self._nut = value + + def generate_nut_kwargs(self): + """ + Generate kwargs which can be used to create new :obj:`.models.SQRLNut`. + + Returns + ------- + dict + All required kwargs to instantiate and create :obj:`.models.SQRLNut`. + """ + randomness = generate_randomness(64) + l = len(randomness) // 2 + + return { + 'session_key': self.get_or_create_session_key(), + 'nonce': randomness[:l], + 'transaction_nonce': randomness[l:], + 'is_transaction_complete': False, + 'ip_address': get_user_ip(self.request), + } + + def get_sqrl_url(self): + """ + Get the server URL of where SQRL client will make first request. + + This method should be customized when a custom namespace should be used + by the SQRL client when generating on the fly per-site public-private keypair. + For example this can be used when a web site is a SAAS in which different + "sub-sites" are determined tenant within a URL path - ``mysaas.com/``. + In that case the returned SQRL auth url should be something like - + ``mysaas.com/mytenant:sqrl/auth/?nut=``. + By using ``:`` within the path will let SQRL client know that up until + that point full domain name should be used to generate public-private keypair. + """ + return reverse('sqrl:auth') + + def get_sqrl_url_params(self): + """ + Get SQRL url params to be added as querystring params in the SQRL url. + + By default this only adds ``nut=``. + + Returns + ------- + str + URLEncoded querystring params + """ + qd = QueryDict('', mutable=True) + qd.update({ + 'nut': self.nut.nonce, + }) + return qd.urlencode() + + @property + def url(self): + """ + Property for getting only server-side SQRL auth view URL. + + This does not include the full domain within the URL. + The URL is always relative to the current domain of the site. + """ + return ( + '{url}?{params}' + ''.format(url=self.get_sqrl_url(), + params=self.get_sqrl_url_params()) + ) + + @property + def sqrl_url(self): + """ + Property for getting full SQRL auth view URL including SQRL scheme and full domain with port. + + Is qrl:// a recognized schema? I can't find documentation of it anywhere... + """ + return ( + '{scheme}://{host}{url}' + ''.format(scheme='sqrl' if self.request.is_secure() else 'qrl', + host=self.request.get_host(), + url=self.url) + ) diff --git a/sqrl/tests.py b/sqrl/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/sqrl/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/sqrl/urls.py b/sqrl/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/sqrl/utils.py b/sqrl/utils.py new file mode 100644 index 0000000..36eb001 --- /dev/null +++ b/sqrl/utils.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +from base64 import urlsafe_b64decode, urlsafe_b64encode +from collections import OrderedDict + +import six + + +class Base64(object): + """ + Helper class for base64 encoding/decoding + """ + + @classmethod + def encode(cls, s): + """ + Encode binary string as base64. Remaining "=" characters are removed. + + Parameters + ---------- + s: bytes + Bytes string to be encoded as base64 + """ + assert isinstance(s, (six.binary_type, bytearray)) + return urlsafe_b64encode(s).decode('ascii').rstrip('=') + + @classmethod + def decode(cls, s): + """ + Decode unicode string from base64 where remaining "=" characters were stripped. + + Parameters + ---------- + s: str + Unicode string to be decoded from base64 + """ + assert isinstance(s, six.text_type) + return urlsafe_b64decode((s + '=' * (4 - len(s) % 4)).encode('ascii')) + + +class Encoder(object): + """ + Helper class for encoding/decoding SQRL response data. + """ + + @classmethod + def base64_dumps(cls, data): + """ + Dumps given data into a single Base64 string. + + Practically this is the same as :meth:`dumps` except :meth:`dumps` + can return multiline string for ``dict``. This method normalizes that + further by converting that multiline string to a single base64 encoded value. + + Returns + ------- + binary + Base64 encoded binary data of input ``data`` + """ + if data and isinstance(data, dict): + return Base64.encode(cls.dumps(data).encode('ascii')) + return cls.dumps(data) + + @classmethod + def dumps(cls, data): + """ + Recursively dumps given data to SQRL response format. + + Before data is dumped out, it is normalized by using :meth:`.normalize`. + + This dumps each data type as follows: + + :``dict``: returns an ``\\r\\n`` multiline string. Each line is for a single key-pair + of format ``=``. + :``list``: tilde (``~``) joined dumped list of values + :other: no operation + """ + data = cls.normalize(data) + + if isinstance(data, dict): + return '\r\n'.join( + '{}={}'.format(k, cls.dumps(v)) + for k, v in data.items() + ) + '\r\n' + elif isinstance(data, (list, tuple)): + return '~'.join(cls.dumps(i) for i in data) + else: + return data + + @classmethod + def normalize(cls, data): + """ + Recursively normalize data for encoding. + + This encodes each data type as follows: + + :``dict``: returns an ``OrderedDict`` where all values are recursively normalized. + Empty dict is normalized to empty string + :``list``: each value is recursively normalized + :``binary``: Base64 encode data + :``str``: no operation + :other: data is casted to string using ``__str__`` (or ``__unicode__``) + """ + if isinstance(data, dict): + if data: + return OrderedDict(( + (k, cls.normalize(v)) + for k, v in data.items() + )) + else: + return '' + elif isinstance(data, (list, tuple)): + return [cls.dumps(i) for i in data] + elif isinstance(data, six.binary_type): + return Base64.encode(data) + elif isinstance(data, six.text_type): + return data + else: + return six.text_type(data) + +def get_user_ip(request): + """ + Utility function for getting user's IP from request address. + + This either returns the IP address from the ``request.REMOTE_ADDR`` + or ``request.META'HTTP_X_REAL_IP']`` when request might of + been reverse proxied. + """ + return ( + request.META.get('HTTP_X_REAL_IP') + or request.META['REMOTE_ADDR'] + ) diff --git a/sqrl/views.py b/sqrl/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/sqrl/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.