Initial commit

This commit is contained in:
= 2019-08-14 11:52:59 -05:00
commit d6ff82eab2
Signed by: kiichan
GPG Key ID: 619DFD67F0976616
14 changed files with 688 additions and 0 deletions

9
CHANGELOG Normal file
View File

@ -0,0 +1,9 @@
Wed, 14 Aug 2019 11:52:59 -0500
Keaton <kii-chan@tutanota.com>
Initial commit
Project is still completely unworkable and unfinished, just starting to initialize things.
--------------------

9
readme.md Normal file
View File

@ -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.

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
ed25519

0
sqrl/__init__.py Normal file
View File

3
sqrl/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
sqrl/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class SqrlConfig(AppConfig):
name = 'sqrl'

134
sqrl/crypto.py Normal file
View File

@ -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 `<http://ed25519.cr.yp.to/>`_.
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))

24
sqrl/managers.py Normal file
View File

@ -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)

219
sqrl/models.py Normal file
View File

@ -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()

147
sqrl/sqrl.py Normal file
View File

@ -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/<tenant>``.
In that case the returned SQRL auth url should be something like -
``mysaas.com/mytenant:sqrl/auth/?nut=<nut value>``.
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=<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)
)

3
sqrl/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

0
sqrl/urls.py Normal file
View File

131
sqrl/utils.py Normal file
View File

@ -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 ``<key>=<dumped value>``.
:``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']
)

3
sqrl/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.