├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── BLURB ├── COPYING ├── MANIFEST.in ├── NEWS ├── README ├── README.adoc ├── dev-requirements.txt ├── doc ├── Apache_Deployment.adoc ├── Database_Setup.adoc ├── Installation.adoc ├── Logging.adoc ├── Metadata_Format.adoc ├── Migrating_From_Older_Versions.adoc ├── Nginx_And_uWSGI_Deployment.adoc ├── REST_API.adoc └── Using_Memcached.adoc ├── man └── u2fval.1.adoc ├── recalc-fingerprints.py ├── release.py ├── setup.cfg ├── setup.py ├── test ├── __init__.py ├── soft_u2f_v2.py └── test_api.py ├── tox.ini └── u2fval ├── __init__.py ├── cli.py ├── core └── __init__.py ├── default_settings.py ├── exc.py ├── jsobjects.py ├── model.py ├── transactiondb.py └── view.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg 3 | *.egg-info 4 | build/ 5 | dist/ 6 | venv/ 7 | *.eggs/ 8 | .ropeproject/ 9 | MANIFEST 10 | ChangeLog 11 | man/*.1 12 | conf/u2fval.conf 13 | 14 | # Unit test / coverage reports 15 | htmlcov/ 16 | .tox/ 17 | .coverage 18 | .coverage.* 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: git://github.com/pre-commit/pre-commit-hooks 2 | sha: 18d7035de5388cc7775be57f529c154bf541aab9 3 | hooks: 4 | - id: flake8 5 | - id: double-quote-string-fixer 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | python: 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | - "3.5" 9 | - "3.6" 10 | - "pypy-5.4.1" 11 | - "pypy3.3-5.5-alpha" 12 | 13 | cache: 14 | directories: 15 | - $HOME/.cache/pip 16 | 17 | addons: 18 | apt: 19 | packages: 20 | - libffi-dev 21 | - libssl-dev 22 | - python-dev 23 | - python-pip 24 | - swig 25 | 26 | install: 27 | - pip install --disable-pip-version-check --upgrade pip setuptools 28 | - pip install -r dev-requirements.txt 29 | - pip install -e . 30 | 31 | script: 32 | - coverage run setup.py test 33 | 34 | after_success: 35 | - coveralls 36 | -------------------------------------------------------------------------------- /BLURB: -------------------------------------------------------------------------------- 1 | Author: Yubico 2 | Basename: u2fval 3 | Homepage: https://developers.yubico.com/u2fval/ 4 | License: BSD-2-Clause 5 | Name: Yubico U2F Validation Server 6 | Project: u2fval 7 | Summary: Python based U2F Validation Server 8 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Yubico AB 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or 5 | without modification, are permitted provided that the following 6 | conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include release.py 2 | include COPYING 3 | include NEWS 4 | include ChangeLog 5 | include doc/* 6 | include man/* 7 | include recalc-fingerprints.py 8 | recursive-include conf * 9 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- 1 | * Version 2.0.1 (unreleased) 2 | 3 | * Version 2.0.0 (released 2017-04-07) 4 | ** Major version: This release is NOT backwards compatible! See 5 | doc/Migrating_From_Older_Versions.adoc for details. 6 | ** Now implements the U2FVAL API version 2, which is aligned with the FIDO 7 | U2F JavaScript API 1.1 and offers more control over operations. 8 | ** Now using Flask. 9 | ** Python 3 support has been improved. 10 | ** New features: 11 | - Challenges can now be specified by the caller. 12 | - Properties can be set when generating a register/authenticate request, 13 | which will be stored on completion of the request. 14 | - Which devices to include in an authentication can be specified by the 15 | caller. 16 | 17 | * Version 1.0.1 (released 2017-02-21) 18 | ** Fixed broken cachetools import. 19 | ** Fix HTTP responses under Python 3 not specifying a charset. 20 | ** Drop Python 2.6 support. 21 | 22 | * Version 1.0.0 (released 2016-03-10) 23 | ** Added support for Python 3 (Python 2 still supported). 24 | ** Uses python-u2flib-server-4.0.0, which replaces M2Crypto with Cryptography. 25 | ** Added transports to Device entries. 26 | ** Added DB migration command, to migrate an older database to the latest 27 | version (see doc/Database_Setup.adoc for details). 28 | 29 | * Version 0.9.1 (released 2015-10-07) 30 | ** Fixed bug with entrypoint for CLI tool. 31 | ** Only install configuration files if the user has permission to do so 32 | to enable installing without root). 33 | ** Fixed argument parsing when is given immediately after --facets. 34 | 35 | * Version 0.9.0 (released 2015-08-05) 36 | ** Public beta release. 37 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | == Yubico U2F Validation Server == 2 | 3 | NOTE: This project is no longer actively maintained. 4 | 5 | The Yubico U2F Validation Server (u2fval) is a server that provides U2F 6 | registration and authentication through a simple JSON based REST API. 7 | 8 | === Installation 9 | u2flib-server is installable by one of three means 10 | 11 | 1. via `pip` 12 | 2. via `git` 13 | 3. via `python setup.py` 14 | 15 | ==== Installation via `pip` ==== 16 | 17 | Run 18 | 19 | pip install u2fval 20 | 21 | Alternatively, you can run: 22 | 23 | pip install u2fval-.tar.gz 24 | 25 | Where the `.tar.gz` file is a source release of the project. 26 | 27 | ==== Installation via `git` ==== 28 | 29 | * Run these commands to check out the source code: 30 | 31 | git clone https://github.com/Yubico/u2fval.git 32 | cd u2fval 33 | git submodule init 34 | git submodule update 35 | 36 | * Build a source release tar ball by running: 37 | 38 | python setup.py sdist 39 | 40 | The resulting build will be created in the `dist/` subdirectory. 41 | 42 | ==== Installation via `python setup.py` ==== 43 | 44 | You can install directly from the git checkout by running the following 45 | commands: 46 | 47 | python setup.py install 48 | 49 | === Configuration === 50 | Configuration is kept in `/etc/yubico/u2fval/u2fval.conf`, see the default 51 | configuration file for more information (also available in the conf/ directory 52 | of any source release of this project). 53 | 54 | The Yubico U2F Validation Server needs an SQL database to work. Optionally a 55 | memcached server can be used to store transient data which doesn't need to be 56 | persisted to the database (if not available this data will be stored in the 57 | main database). The default configuration uses an in-memory SQLite3 database 58 | which you probably want to change to something like 59 | 60 | SQLALCHEMY_DATABASE_URI = 'sqlite:////etc/yubico/u2fval/u2fval.db' 61 | 62 | Once the configuration file has been configured with database 63 | credentials, the database can be initialized by running the following command: 64 | 65 | u2fval db init 66 | 67 | === API Clients === 68 | To be able to use the server, a client needs to be created. This is done using 69 | the `u2fval client create` command. For example: 70 | 71 | u2fval client create example \ 72 | https://example.com/app-identity.json \ 73 | https://example.com 74 | 75 | See `u2fval client create --help` for more information. 76 | 77 | ==== Authenticating Clients ==== 78 | Each client request needs to be authenticated. This authentication is outside 79 | of the scope of the Yubico U2F Validation Server and can be handled by the 80 | webserver or some WSGI middleware. Once authenticated, the client name should 81 | be set in the REMOTE_USER server environment variable. 82 | 83 | === Deployment === 84 | The server can either be run standalone (intended for testing purposes) using 85 | the `u2fval run` command, or be hosted by any WSGI capable web server, such as 86 | Apache with mod_wsgi enabled. 87 | 88 | === Accessing the Server === 89 | Once the server is set up and at least one client has been created, the client 90 | can access the server via the REST API. Find the API documentation in the doc/ 91 | directory. 92 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | README -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | coveralls 3 | -------------------------------------------------------------------------------- /doc/Apache_Deployment.adoc: -------------------------------------------------------------------------------- 1 | == Apache Deployment == 2 | The Yubico U2F Validation Server is designed as a WSGI module, and should run 3 | under any WSGI capable web server. This document describes one possible setup 4 | for running it under the Apache2 Web Server, using mod_wsgi. 5 | 6 | === Prerequisites === 7 | Before starting, it is assumed that you have installed u2fval and configured it 8 | with a compatible SQL database (see link:Installation.adoc[Installation] and 9 | link:Database_Setup.adoc[Database Setup]). It is assumed that u2fval is 10 | installed in a virtualenv located at `/home/someuser/u2fval/venv`. Change the 11 | path in accordance to your setup. 12 | 13 | === Installation === 14 | Refer to the http://httpd.apache.org[Apache Web Server documentation] for 15 | instructions on setting up the Apache Web Server on your platform. This 16 | document assumes you are running Ubuntu 14.04 or later, and some of the 17 | commands might be slightly different on other platforms. To install Apache and 18 | mod_wsgi (as well as some utilities that we will be using) on Ubuntu, run the 19 | following command: 20 | 21 | # apt-get install apache2 apache2-utils libapache2-mod-wsgi 22 | 23 | This should automatically enable mod_wsgi, which you can confirm bu running: 24 | 25 | # a2query -m wsgi 26 | 27 | ...which should print something along the lines of: 28 | 29 | wsgi (enabled by maintainer script) 30 | 31 | You will also need to enable mod_auth_digest, as we will be using 32 | http://httpd.apache.org/docs/2.2/mod/mod_auth_digest.html[HTTP Digest authentication]. 33 | To enable it, run: 34 | 35 | # a2enmod auth_digest 36 | 37 | === Configuration === 38 | Create the file `/home/someuser/u2fval/u2fval.wsgi` and add the following content: 39 | [source,python] 40 | ---- 41 | from u2fval import app as application 42 | ---- 43 | 44 | Create the file: `/etc/apache2/conf-available/u2fval.conf` and add the following 45 | content to it: 46 | [source,xml] 47 | ---- 48 | 49 | WSGIDaemonProcess u2fval python-home=/home/someuser/u2fval/venv 50 | WSGIApplicationGroup %{GLOBAL} 51 | 52 | WSGIScriptAlias /wsapi/u2fval /home/someuser/u2fval/u2fval.wsgi process-group=u2fval 53 | 54 | 55 | Options None 56 | AllowOverride None 57 | AuthType Digest 58 | AuthName "u2fval" 59 | AuthUserFile /home/someuser/u2fval/clients.htdigest 60 | Require valid-user 61 | 62 | 63 | ---- 64 | 65 | The above configuration points out an AuthUserFile which does not yet exist. 66 | This is where client credentials will be stored, so let's create the file and 67 | add our first client now: 68 | 69 | $ htdigest -c /home/someuser/u2fval/clients.htdigest "u2fval" testclient 70 | 71 | You will now be prompted for a password for the client. Once entered, the 72 | client can be authenticated using the _testclient_ username, with the password 73 | you just assigned. To add more users just run the same command as above, but 74 | without the -c argument (which is only needed to create the file). 75 | 76 | For each created user you also need to create the corresponding client in the 77 | u2fval database. This is done by using the u2fval command line tool: 78 | 79 | $ u2fval client create testclient http://example.com 80 | 81 | The client name _testclient_ above needs to match the name used for the 82 | htdigest command. The -a argument defines the application ID of the client, and 83 | the -f argument sets the valid facets for the client (for multiple facets, 84 | separate them with spaces). For more information about these parameters, 85 | https://developers.yubico.com/U2F/[click here]. If you need to change these 86 | settings later you can use the u2fval tool (run "u2fval -h" for usage). 87 | 88 | Now all that remains is to activate the Apache configuration: 89 | 90 | # a2enconf u2fval 91 | # service apache2 reload 92 | 93 | You should now be all set! You can verify that the server works by running a 94 | request against it as the client you just created: 95 | 96 | $ curl --digest -u'testclient:password' http://localhost/wsapi/u2fval/ 97 | 98 | Alter the above command to match the username and password you set for the 99 | client. If successful the output should contain some information about the 100 | client, such as the application ID and valid facets. 101 | 102 | ==== Logging 103 | You can customize logging by modifying the `u2fval.wsgi` file above. See 104 | link:Logging.adoc[Logging] for more details. Any changes to the file will 105 | require reloading the Apache configuration: 106 | 107 | # service apache2 reload 108 | 109 | -------------------------------------------------------------------------------- /doc/Database_Setup.adoc: -------------------------------------------------------------------------------- 1 | == Database Setup 2 | The Yubico U2F Validation Server requires an SQL database for storing data. 3 | Internally, u2fval uses http://www.sqlalchemy.org[SQLAlchemy] to connect to 4 | the database. Depending on which SQL database you use you might need to install 5 | an adapter for it. For a list of supported databases, and the required adapters, 6 | see http://docs.sqlalchemy.org/en/latest/core/engines.html[SQLAlchemy Engine Configuration]. 7 | 8 | This document assumes you are using Ubuntu with the http://www.postgresql.org[Postgresql] 9 | database. Start by installing Postgresql: 10 | 11 | # apt-get install postgresql 12 | 13 | You will also need to install the required database adapter (assuming you're 14 | using a virtualenv): 15 | 16 | $ source venv/bin/activate 17 | (venv) $ pip install psycopg2 18 | 19 | To create a user and a database for u2fval, start by opening the psql prompt: 20 | 21 | # su postgres -c psql 22 | 23 | Now create a user and a database: 24 | 25 | postgres=# create user u2fval with password 'password'; 26 | postgres=# create database u2fval with owner u2fval; 27 | postgres=# \q 28 | 29 | We're done. We now have a database named "u2fval", with a user named "u2fval", 30 | and the password "password". This gives us the following connection string: 31 | 32 | postgresql://u2fval:password@localhost/u2fval 33 | 34 | Edit the SQLALCHEMY_DATABASE_URI setting in the 35 | `/etc/yubico/u2fval/u2fval.conf` file with the connection string, so it reads: 36 | 37 | SQLALCHEMY_DATABASE_URI = 'postgresql://u2fval:password@localhost/u2fval' 38 | 39 | NOTE: There are many other settings related to the database that you can set in 40 | this file. For a reference, see the 41 | link:http://flask-sqlalchemy.pocoo.org/2.1/config/[Flask-SQLAlchemy 42 | configuration] documentation. 43 | 44 | Save and close the file, and initialize the database for use with u2fval: 45 | 46 | (venv) $ u2fval db init 47 | 48 | You can optionally specify a different configuration file to use, by setting 49 | the U2FVAL_SETTINGS environment variable. 50 | 51 | That's it, the database is now configured and ready. 52 | -------------------------------------------------------------------------------- /doc/Installation.adoc: -------------------------------------------------------------------------------- 1 | == Installation 2 | The recommended way to install the Yubico U2F Validation Server is by using pip 3 | in a virtual environment (virtualenv). This document will guide you through 4 | the setup and configuration of the server. It assumes you are familliar with 5 | using virtualenv in Python. 6 | 7 | === Build dependencies 8 | To build the project and its dependencies, you first need to install some build 9 | dependencies. On Debian or Ubuntu this can be done by running: 10 | 11 | $ sudo apt-get install build-essential libssl-dev libffi-dev python-dev python-virtualenv 12 | 13 | === Create a virtual environment 14 | To begin, create a new virtual environment and install u2fval: 15 | 16 | $ virtualenv venv 17 | $ source venv/bin/activate 18 | (venv) $ pip install -U pip 19 | (venv) $ pip install u2fval 20 | 21 | === Configuration 22 | By default, u2fval will use the file `/etc/yubico/u2fval/u2fval.conf` for 23 | configuration. This can be overridden by setting the U2FVAL_SETTINGS 24 | environment variable to point to an alternative location. For an example of 25 | this file, you can look at the `conf/u2fval.conf` file in the source release 26 | tar.gz. 27 | 28 | To use custom U2F device metadata, you can place metadata json files in 29 | `/etc/yubico/u2fval/metadata/` or whatever location you specify in the 30 | configuration file. By default, the bundled metadata from python-u2flib-server 31 | is used. 32 | 33 | To configure logging, see link:Logging.adoc[Logging]. 34 | 35 | === Next steps 36 | Before you can use the server you need to configure and initialize a database. 37 | See link:Database_Setup.adoc[Database Setup] for an example on how to do that. 38 | 39 | Once the database is set up, you will need to create at least one client, and 40 | deploy the server in some way. See 41 | link:Apache_Deployment.adoc[Apache Deployment] for an example on how to do 42 | that. 43 | -------------------------------------------------------------------------------- /doc/Logging.adoc: -------------------------------------------------------------------------------- 1 | == Logging 2 | By default u2fval uses the built-in logging in Flask. This is handy for 3 | development purposes, but may not be sufficient for production use. We 4 | recommend deploying u2fval in a WSGI container when used in production and 5 | configuring logging in a `.wsgi` file. Here is an example of such a file: 6 | 7 | [source,python] 8 | ---- 9 | from u2fval import app 10 | import logging 11 | from logging.handlers import RotatingFileHandler 12 | 13 | # First we remove the default logging handlers. 14 | for handler in app.logger.handlers: 15 | app.logger.removeHandler(handler) 16 | 17 | # Now we add our own. 18 | handler = RotatingFileHandler('/var/log/u2fval.log', maxBytes=100000, backupCount=1) 19 | handler.setLevel(logging.DEBUG) 20 | formatter = logging.Formatter('[%(levelname)s] %(asctime)s %(name)s: %(message)s', '%Y-%m-%d %I:%M:%S') 21 | handler.setFormatter(formatter) 22 | app.logger.addHandler(handler) 23 | 24 | application = app 25 | ---- 26 | 27 | NOTE: You need to ensure that the server has permissions to write to the log 28 | file, if using one of the Handlers that write to a file. For more information 29 | on logging see 30 | link:https://docs.python.org/3/library/logging.html[the Python logging module]. 31 | -------------------------------------------------------------------------------- /doc/Metadata_Format.adoc: -------------------------------------------------------------------------------- 1 | == U2F JSON metadata format specification 2 | Attestation and metadata is provided as +MetadataObjects+, as defined in this 3 | document. These objects might be stored as files in a file system (one object 4 | per file), a single file (containing a list of multiple objects), as data in a 5 | database, or any combination thereof. 6 | 7 | === MetadataObject 8 | [source, javascript] 9 | ---- 10 | dictionary MetadataObject { 11 | DOMString identifier; 12 | unsigned long version; 13 | DOMString[] trustedCertificates; 14 | optional VendorInfo vendorInfo; 15 | optional DeviceInfo[]? devices; 16 | } 17 | ---- 18 | 19 | The metadata object is uniquely identified by the identifier property, which 20 | MUST be globally unique. The version property identifies the version of the 21 | object, with higher versions being newer. The trustedCertificates property 22 | consists of a list of PEM encoded certificates that are trusted for attestation 23 | certificate validation. Intermediate certificates may be present in the chain, 24 | between one of the trusted certificates and the attestation certificates, but 25 | how these certificates are provided is outside of the scope for this document. 26 | A certificate in the trustedCertificates list is trusted, and its own signature 27 | is not regarded (whether it be self-signed or not). The devices property is 28 | optional, and used to provide metadata about different device types. 29 | 30 | === VendorInfo 31 | [source, javascript] 32 | ---- 33 | dictionary VendorInfo { 34 | DOMString name; 35 | DOMString url; 36 | DOMString imageUrl; 37 | } 38 | ---- 39 | 40 | The vendor info object contains information about the vendor, such as name and 41 | URL. Additional fields may be added to provide more information. 42 | 43 | === DeviceInfo 44 | [source, javascript] 45 | ---- 46 | dictionary DeviceInfo { 47 | DOMString deviceId; 48 | optional DOMString displayName; 49 | optional DOMString imageUrl; 50 | optional DOMString deviceUrl; 51 | optional Selector[] selectors; 52 | } 53 | ---- 54 | 55 | The device object provides metadata for a U2F device. The deviceId is a unique 56 | identifier which can be used to update previously identified devices if the 57 | metadata changes. Additional fields may be added to provide more information. 58 | The selector is used to match a device object to a given attestation 59 | certificate. When not present, the device will match any certificate as long as 60 | it is trusted by one of the roots from the same metadata object. 61 | 62 | === Selector 63 | [source, javascript] 64 | ---- 65 | dictionary Selector { 66 | DOMString type; 67 | optional dictionary parameters; 68 | } 69 | ---- 70 | 71 | The selector object provides a way to match an attestation certificate against 72 | a device object. When present in a device object, each supported selector type 73 | is tested against the attestation certificate. If any of them match, the device 74 | matches. If none of the selectors match, the device does not match. An empty 75 | selectors list [], cannot be matched by any attestation certificate. However, a 76 | null or missing selectors field is matched by any certificate. 77 | 78 | ==== Selector types 79 | The selector type is defined by the string in the type field. The parameters 80 | field is type dependent and can contain arbitrary data. 81 | 82 | ===== Fingerprint 83 | Match certificates by their SHA1 fingerprint. 84 | 85 | Type value:: 86 | +fingerprint+ 87 | 88 | Parameters type:: 89 | [source, javascript] 90 | ---- 91 | dictionary FingerprintParameters { 92 | DOMString[] fingerprints; 93 | } 94 | ---- 95 | The fingerprints field contains hex values of SHA1 fingerprints of attestation 96 | certificates to match against. 97 | 98 | ===== x509Extension 99 | Match certificates by their extensions. 100 | 101 | Type value:: 102 | +x509Extension+ 103 | 104 | Parameters type:: 105 | [source, javascript] 106 | ---- 107 | dictionary X509ExtensionParameters { 108 | DOMString key; 109 | optional DOMString value; 110 | } 111 | ---- 112 | The key field contains the textual representation of the OID of the extension 113 | to match against. The value field contains the matching value when compared to 114 | the extensions OctetString value when interpreted as an ASCII string. If the 115 | value field is missing, any value will be accepted, as long as the certificate 116 | has the extension with the given key. 117 | -------------------------------------------------------------------------------- /doc/Migrating_From_Older_Versions.adoc: -------------------------------------------------------------------------------- 1 | == Migrating From Older Versions 2 | When installing a new major version (X.0.0) of U2FVAL you may have to make some 3 | adjustments before it is usable. This documents lists backwards incompatible 4 | changes between versions. 5 | 6 | === Version 2.0.0 7 | ==== General changes 8 | The DATABASE_CONFIGURATION setting has been renamed to SQLALCHEMY_DATABASE_URI. 9 | The old name continues to work for now, but will log a warning. 10 | 11 | The "wsgi.py" file has been removed from this release. You can simply provide 12 | your own WSGI file to run the application in a WSGI container. At minimal, such 13 | a file should contain: 14 | 15 | from u2fval import app as application 16 | 17 | See link:Apache_Deployment.adoc[Apache Deployment] for an example of how this 18 | can be done. 19 | 20 | ==== Command line tool changes 21 | The command line utility has been rewritten, and the arguments may have changed 22 | slightly. Check the output of `u2fval --help` or `u2fval [command] --help` for 23 | more details. Providing a configuration file to use can now be done via the 24 | U2FVAL_SETTINGS environment variable. 25 | 26 | ==== API changes 27 | This version uses a new version of the U2FVAL API. For a full specification, 28 | see https://developers.yubico.com/U2F/Standalone_servers/U2FVAL_REST_API.html. 29 | The structure of the responses have been changed to align with the FIDO U2F 30 | JavaScript API v1.1 31 | 32 | ==== Database changes 33 | Some database columns have changed in this version. To migrate existing data 34 | from an older version of U2FVAL follow these steps: 35 | 36 | 1. Dump the data from the database using mysqldump, pg_dump or similar for your 37 | database. Dump the data only, not the table structure. Verify that the dump 38 | contains your data! 39 | 2. Drop the tables from the database, then re-create them using the 40 | `u2fval db init` command from the new version of u2fval. 41 | 3. Import the data from step 1 into the new tables. 42 | 4. Run the recalculate-fingerprints.py script with the connection string for 43 | your database (located in the source .tar.gz): 44 | 45 | $ python recalc-fingerprints.py "sqlite:////path/to/database.db" 46 | 47 | You can manually change the columns in-place as an alternative, but you will 48 | still need to run the `recalc-fingerprints.py` afterwards. It is advised you 49 | create a backup of all data before attempting this. 50 | 51 | Detailed changes: 52 | - The `name` column in the `clients` table has been resized to 40 characters to 53 | be in line with other name columns. 54 | - The `key` column in the `properties` table has been resized to 40 characters 55 | to be in line with other name columns. 56 | - The `fingerprint` column in the `certificates` table has been resized to 128 57 | characters to accommodate larger fingerprints. 58 | 59 | ==== Logging changes 60 | The app now uses Flask's built-in logging which is based on the logging module 61 | included in Python. The recommended way to configure custom logging for 62 | production is by creating a `.wsgi` file to serve the app in a wsgi container, 63 | and configuring the logging in that file. See link:Logging.adoc[Logging] for 64 | more details. 65 | -------------------------------------------------------------------------------- /doc/Nginx_And_uWSGI_Deployment.adoc: -------------------------------------------------------------------------------- 1 | == Deploying u2fval with Nginx and uWSGI 2 | To deploy u2fval in a microservices environment, you can use uWSGI as a robust 3 | application server and use nginx, which has uWSGI support built-in, as a proxy 4 | server. 5 | 6 | Install uWSGI on the same server where you had `u2fval` installed beforehand, 7 | preferably with `pip install uwsgi`, and create the `/etc/uwsgi.ini` file: 8 | 9 | [uwsgi] 10 | master = true 11 | processes = 4 12 | socket = :8000 13 | uid = nobody 14 | buffer-size = 65535 15 | module = u2fval 16 | callable = app 17 | 18 | uWSGI can be started in daemon mode: 19 | 20 | uwsgi -d /etc/uwsgi.ini 21 | 22 | In nginx, add the following server definition, replacing `server_name` by your 23 | own domain name and `u2fval_user` by the user that you had setup with `u2fval` 24 | previously. 25 | 26 | server { 27 | listen 443; 28 | # ssl certificate configuration would go here 29 | server_name yourauthserver.com; 30 | location /u2f { 31 | uwsgi_pass 127.0.0.1:8000; 32 | include uwsgi_params; 33 | uwsgi_param REMOTE_USER u2fval_user; 34 | } 35 | } 36 | 37 | You now can access your u2fval server at https://yourauthserver.com/u2f 38 | -------------------------------------------------------------------------------- /doc/REST_API.adoc: -------------------------------------------------------------------------------- 1 | == REST API 2 | The Yubico U2FVAL server implements the 3 | https://developers.yubico.com/U2F/Standalone_servers/U2FVAL_REST_API_V2.html[U2FVAL REST API v2]. 4 | 5 | Consuming this API is made easier by use of 6 | https://developers.yubico.com/Software_Projects/FIDO_U2F/U2FVAL_Connector_Libraries/[U2FVAL connector libraries]. 7 | -------------------------------------------------------------------------------- /doc/Using_Memcached.adoc: -------------------------------------------------------------------------------- 1 | == Using Memcached == 2 | Memcached use is completely optional. By default, the Yubico U2F Validation 3 | Server does not use Memcached, and you do not need to install or configure it. 4 | 5 | === Why use Memcached? === 6 | The u2fval server deals with a lot of short-lived data. Each U2F registration 7 | or authentication requires some state about the request to be stored for a 8 | short period of time, until the client responds to complete the request. By 9 | default, this is stored in the database. By enabling u2fval to use a Memcached 10 | server, this data is instead stored in memory, which is more performant. 11 | 12 | === Installation === 13 | Refer to the https://memcached.org[Memcached documentation] for instructions on 14 | setting up Memcached on your platform. On Ubuntu this is installed using: 15 | 16 | # apt-get install memcached 17 | 18 | You will also need to have the Python bindings for memcached installed, which 19 | can be done by installing u2fval with memcache support: 20 | 21 | $ pip install u2fval[memcache] 22 | 23 | === Configuration === 24 | To configure the Yubico U2F Validation Server to use Memcached, edit the 25 | /etc/yubico/u2fval/u2fval.conf file and change the USE_MEMCACHED variable to be 26 | True instead of the default of False. If the Memcached server is running on a 27 | non-standard port, or on a different machine, you will have to modify the 28 | MEMCACHED_SERVERS setting. 29 | 30 | Once configured you will need to restart the u2fval server for the changes to 31 | take effect. 32 | -------------------------------------------------------------------------------- /man/u2fval.1.adoc: -------------------------------------------------------------------------------- 1 | u2fval(1) 2 | ========= 3 | :doctype: manpage 4 | :man source: u2fval 5 | :man manual: u2fval manual 6 | 7 | == Name 8 | u2fval - U2F validation server implementing the U2FVAL protocol. 9 | 10 | == Synopsis 11 | *u2fval* [_options_] _command_ 12 | 13 | == Description 14 | The Yubico U2F Validation Server (u2fval) is a server that provides U2F 15 | registration and authentication through a simple JSON based REST API. 16 | 17 | == Options 18 | u2fval has the following options: 19 | 20 | *-h, --help*:: 21 | Shows a list of available sub commands and arguments. 22 | 23 | *--config CONFIG*:: 24 | Specify an alternate configuration file to use. 25 | 26 | == Commands 27 | u2fval supports multiple commands, each with its own options, in addition 28 | to the global options: 29 | 30 | === *u2fval run* [OPTIONS] 31 | Runs the standalone server. 32 | 33 | *-i, --interface INTERFACE*:: 34 | Network interface to bind to. 35 | 36 | *-p, --port PORT*:: 37 | TCP port to bind to. 38 | 39 | *-c, --client CLIENT*:: 40 | Run the server in single client mode using CLIENT. 41 | 42 | *-d, --debug*:: 43 | Run the server in debug mode using HTTP basic authentication with no 44 | password to specify client. 45 | 46 | === *u2fval client list* 47 | Lists all clients. 48 | 49 | === *u2fval client create NAME APPID [FACETS]...* 50 | Creates a new client, with the given AppID and valid facets. 51 | 52 | *NAME*:: 53 | The name to give the client. 54 | 55 | *APPID*:: 56 | The AppID of the client. 57 | 58 | *FACETS*:: 59 | One or more valid facets for the client. If no facet is given, APPID is 60 | used as the only valid facet. This requires the APPID itself to be a valid 61 | web origin. 62 | 63 | === *u2fval client show NAME* 64 | Shows information about a client. 65 | 66 | *NAME*:: 67 | The name of the client to show. 68 | 69 | === *u2fval client update NAME APPID [FACETS]...* 70 | Updates data for a client. 71 | 72 | *NAME*:: 73 | The name of the client to change. 74 | 75 | *APPID*:: 76 | The AppID to set. 77 | 78 | *FACETS*:: 79 | One or more valid facets for the client. If no facet is given, APPID is 80 | used as the only valid facet. This requires the APPID itself to be a valid 81 | web origin. 82 | 83 | === *u2fval client delete NAME* 84 | Deletes a client. 85 | 86 | *NAME*:: 87 | The name of the client to delete. 88 | 89 | === *u2fval db init* 90 | Initializes the database, creating as needed tables. 91 | 92 | == Bugs 93 | Report bugs in the issue tracker (https://github.com/Yubico/u2fval/issues) 94 | -------------------------------------------------------------------------------- /recalc-fingerprints.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | # flake8: noqa 4 | import os 5 | os.environ['U2FVAL_SETTINGS'] = '/dev/null' # Avoid loading config file 6 | from u2fval import app 7 | from u2fval.model import db, Certificate, _calculate_fingerprint 8 | from cryptography import x509 9 | from cryptography.hazmat.backends import default_backend 10 | import click 11 | 12 | 13 | @click.command() 14 | @click.argument('db-uri') 15 | def rewrite_certs(db_uri): 16 | """Re-caclulates fingerprints for all certificates""" 17 | click.confirm('Re-calculate certificate fingerprints?', abort=True) 18 | 19 | app.config['SQLALCHEMY_DATABASE_URI'] = db_uri 20 | 21 | changed = 0 22 | total = 0 23 | for cert in Certificate.query.all(): 24 | total += 1 25 | c = x509.load_der_x509_certificate(cert.der, default_backend()) 26 | old_fp = cert.fingerprint 27 | new_fp = _calculate_fingerprint(c) 28 | if new_fp != old_fp: 29 | changed += 1 30 | cert.fingerprint = new_fp 31 | db.session.commit() 32 | click.echo('Success! %d/%d certificates were modified.' % (changed, total)) 33 | 34 | 35 | if __name__ == '__main__': 36 | rewrite_certs() 37 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from __future__ import absolute_import 29 | 30 | 31 | from setuptools import setup as _setup, find_packages, Command 32 | from setuptools.command.sdist import sdist 33 | from distutils import log 34 | from distutils.errors import DistutilsSetupError 35 | from datetime import date 36 | from glob import glob 37 | import os 38 | import re 39 | 40 | VERSION_PATTERN = re.compile(r"(?m)^__version__\s*=\s*['\"](.+)['\"]$") 41 | 42 | base_module = __name__.rsplit('.', 1)[0] 43 | 44 | 45 | def get_version(module_name_or_file=None): 46 | """Return the current version as defined by the given module/file.""" 47 | 48 | if module_name_or_file is None: 49 | parts = base_module.split('.') 50 | module_name_or_file = parts[0] if len(parts) > 1 else \ 51 | find_packages(exclude=['test', 'test.*'])[0] 52 | 53 | if os.path.isdir(module_name_or_file): 54 | module_name_or_file = os.path.join(module_name_or_file, '__init__.py') 55 | 56 | with open(module_name_or_file, 'r') as f: 57 | match = VERSION_PATTERN.search(f.read()) 58 | return match.group(1) 59 | 60 | 61 | def setup(**kwargs): 62 | if 'version' not in kwargs: 63 | kwargs['version'] = get_version() 64 | kwargs.setdefault('packages', find_packages(exclude=['test', 'test.*'])) 65 | cmdclass = kwargs.setdefault('cmdclass', {}) 66 | cmdclass.setdefault('release', release) 67 | cmdclass.setdefault('build_man', build_man) 68 | cmdclass.setdefault('sdist', custom_sdist) 69 | return _setup(**kwargs) 70 | 71 | 72 | class custom_sdist(sdist): 73 | def run(self): 74 | self.run_command('build_man') 75 | 76 | sdist.run(self) 77 | 78 | 79 | class build_man(Command): 80 | description = 'create man pages from asciidoc source' 81 | user_options = [] 82 | boolean_options = [] 83 | 84 | def initialize_options(self): 85 | pass 86 | 87 | def finalize_options(self): 88 | self.cwd = os.getcwd() 89 | self.fullname = self.distribution.get_fullname() 90 | self.name = self.distribution.get_name() 91 | self.version = self.distribution.get_version() 92 | 93 | def run(self): 94 | if os.getcwd() != self.cwd: 95 | raise DistutilsSetupError('Must be in package root!') 96 | 97 | for fname in glob(os.path.join('man', '*.adoc')): 98 | self.announce('Converting: ' + fname, log.INFO) 99 | self.execute(os.system, 100 | ('a2x -d manpage -f manpage "%s"' % fname,)) 101 | 102 | 103 | class release(Command): 104 | description = 'create and release a new version' 105 | user_options = [ 106 | ('keyid', None, 'GPG key to sign with'), 107 | ('skip-tests', None, 'skip running the tests'), 108 | ('pypi', None, 'publish to pypi'), 109 | ] 110 | boolean_options = ['skip-tests', 'pypi'] 111 | 112 | def initialize_options(self): 113 | self.keyid = None 114 | self.skip_tests = 0 115 | self.pypi = 0 116 | 117 | def finalize_options(self): 118 | self.cwd = os.getcwd() 119 | self.fullname = self.distribution.get_fullname() 120 | self.name = self.distribution.get_name() 121 | self.version = self.distribution.get_version() 122 | 123 | def _verify_version(self): 124 | with open('NEWS', 'r') as news_file: 125 | line = news_file.readline() 126 | now = date.today().strftime('%Y-%m-%d') 127 | if not re.search(r'Version %s \(released %s\)' % (self.version, now), 128 | line): 129 | raise DistutilsSetupError('Incorrect date/version in NEWS!') 130 | 131 | def _verify_tag(self): 132 | if os.system('git tag | grep -q "^%s\$"' % self.fullname) == 0: 133 | raise DistutilsSetupError( 134 | "Tag '%s' already exists!" % self.fullname) 135 | 136 | def _verify_not_dirty(self): 137 | if os.system('git diff --shortstat | grep -q "."') == 0: 138 | raise DistutilsSetupError('Git has uncommitted changes!') 139 | 140 | def _sign(self): 141 | if os.path.isfile('dist/%s.tar.gz.asc' % self.fullname): 142 | # Signature exists from upload, re-use it: 143 | sign_opts = ['--output dist/%s.tar.gz.sig' % self.fullname, 144 | '--dearmor dist/%s.tar.gz.asc' % self.fullname] 145 | else: 146 | # No signature, create it: 147 | sign_opts = ['--detach-sign', 'dist/%s.tar.gz' % self.fullname] 148 | if self.keyid: 149 | sign_opts.insert(1, '--default-key ' + self.keyid) 150 | self.execute(os.system, ('gpg ' + (' '.join(sign_opts)),)) 151 | 152 | if os.system('gpg --verify dist/%s.tar.gz.sig' % self.fullname) != 0: 153 | raise DistutilsSetupError('Error verifying signature!') 154 | 155 | def _tag(self): 156 | tag_opts = ['-s', '-m ' + self.fullname, self.fullname] 157 | if self.keyid: 158 | tag_opts[0] = '-u ' + self.keyid 159 | self.execute(os.system, ('git tag ' + (' '.join(tag_opts)),)) 160 | 161 | def run(self): 162 | if os.getcwd() != self.cwd: 163 | raise DistutilsSetupError('Must be in package root!') 164 | 165 | self._verify_version() 166 | self._verify_tag() 167 | self._verify_not_dirty() 168 | self.run_command('check') 169 | 170 | self.execute(os.system, ('git2cl > ChangeLog',)) 171 | 172 | self.run_command('sdist') 173 | 174 | if not self.skip_tests: 175 | try: 176 | self.run_command('test') 177 | except SystemExit as e: 178 | if e.code != 0: 179 | raise DistutilsSetupError('There were test failures!') 180 | 181 | if self.pypi: 182 | cmd_obj = self.distribution.get_command_obj('upload') 183 | cmd_obj.sign = True 184 | if self.keyid: 185 | cmd_obj.identity = self.keyid 186 | self.run_command('upload') 187 | 188 | self._sign() 189 | self._tag() 190 | 191 | self.announce("Release complete! Don't forget to:", log.INFO) 192 | self.announce('') 193 | self.announce(' git push && git push --tags', log.INFO) 194 | self.announce('') 195 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3 | # 3. If at all possible, it is good practice to do this. If you cannot, you 4 | # will need to generate wheels for each Python version that you support. 5 | universal=1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from __future__ import print_function 29 | from release import setup 30 | import sys 31 | import os 32 | 33 | 34 | # Make sure the cont/u2fval.conf file exists and is a copy of 35 | # u2fval/default_settings.py when building a release. 36 | if [_ for _ in ['sdist'] if _ in sys.argv[1:]]: 37 | print('copying default settings...') 38 | source = os.path.abspath('u2fval/default_settings.py') 39 | target = os.path.abspath('conf/u2fval.conf') 40 | target_dir = os.path.dirname(target) 41 | if not os.path.isdir(target_dir): 42 | os.mkdir(target_dir) 43 | with open(target, 'w') as target_f: 44 | with open(source, 'r') as source_f: 45 | target_f.write(source_f.read()) 46 | os.chmod(target, 0o600) 47 | 48 | 49 | setup( 50 | name='u2fval', 51 | author='Dain Nilsson', 52 | author_email='dain@yubico.com', 53 | maintainer='Yubico Open Source Maintainers', 54 | maintainer_email='ossmaint@yubico.com', 55 | description='Standalone/WSGI U2F server implementing the U2FVAL protocol', 56 | url='https://github.com/Yubico/u2fval', 57 | license='BSD 2 clause', 58 | entry_points={ 59 | 'console_scripts': [ 60 | 'u2fval=u2fval.cli:main' 61 | ] 62 | }, 63 | install_requires=[ 64 | 'python-u2flib-server >= 5, <6', 65 | 'flask', 66 | 'flask-sqlalchemy' 67 | ], 68 | test_suite='test', 69 | extras_require={ 70 | 'memcache': ['python-memcached'] 71 | }, 72 | classifiers=[ 73 | 'License :: OSI Approved :: BSD License', 74 | 'Operating System :: OS Independent', 75 | 'Development Status :: 4 - Beta', 76 | 'Intended Audience :: Developers', 77 | 'Intended Audience :: System Administrators', 78 | 'Programming Language :: Python :: 2', 79 | 'Programming Language :: Python :: 2.7', 80 | 'Programming Language :: Python :: 3', 81 | 'Programming Language :: Python :: 3.3', 82 | 'Programming Language :: Python :: 3.4', 83 | 'Programming Language :: Python :: 3.5', 84 | 'Programming Language :: Python :: 3.6', 85 | 'Programming Language :: Python :: Implementation :: PyPy', 86 | 'Topic :: Internet', 87 | 'Topic :: Security', 88 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application' 89 | ] 90 | ) 91 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yubico/u2fval/ec8b6b9e65f880fd609c7e9f82638696341c781f/test/__init__.py -------------------------------------------------------------------------------- /test/soft_u2f_v2.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from u2flib_server.utils import websafe_encode, sha_256 29 | from u2flib_server.model import (RegisterRequest, RegisterResponse, 30 | SignResponse, ClientData, Type, RegisteredKey) 31 | from base64 import b64decode 32 | import six 33 | import struct 34 | import os 35 | 36 | from cryptography.hazmat.primitives.asymmetric import ec 37 | from cryptography.hazmat.primitives.serialization import PublicFormat, Encoding 38 | from cryptography.hazmat.backends import default_backend 39 | from cryptography.hazmat.primitives.serialization import load_pem_private_key 40 | from cryptography.hazmat.primitives import hashes 41 | 42 | CURVE = ec.SECP256R1 43 | 44 | CERT = b64decode(b""" 45 | MIIBhzCCAS6gAwIBAgIJAJm+6LEMouwcMAkGByqGSM49BAEwITEfMB0GA1UEAwwW 46 | WXViaWNvIFUyRiBTb2Z0IERldmljZTAeFw0xMzA3MTcxNDIxMDNaFw0xNjA3MTYx 47 | NDIxMDNaMCExHzAdBgNVBAMMFll1YmljbyBVMkYgU29mdCBEZXZpY2UwWTATBgcq 48 | hkjOPQIBBggqhkjOPQMBBwNCAAQ74Zfdc36YPZ+w3gnnXEPIBl1J3pol6IviRAMc 49 | /hCIZFbDDwMs4bSWeFdwqjGfjDlICArdmjMWnDF/XCGvHYEto1AwTjAdBgNVHQ4E 50 | FgQUDai/k1dOImjupkubYxhOkoX3sZ4wHwYDVR0jBBgwFoAUDai/k1dOImjupkub 51 | YxhOkoX3sZ4wDAYDVR0TBAUwAwEB/zAJBgcqhkjOPQQBA0gAMEUCIFyVmXW7zlnY 52 | VWhuyCbZ+OKNtSpovBB7A5OHAH52dK9/AiEA+mT4tz5eJV8W2OwVxcq6ZIjrwqXc 53 | jXSy2G0k27yAUDk= 54 | """) 55 | 56 | CERT_PRIV = b""" 57 | -----BEGIN EC PRIVATE KEY----- 58 | MHcCAQEEIMyk3gKcDg5lsYdl48fZoIFORhAc9cQxmn2Whv/+ya+2oAoGCCqGSM49 59 | AwEHoUQDQgAEO+GX3XN+mD2fsN4J51xDyAZdSd6aJeiL4kQDHP4QiGRWww8DLOG0 60 | lnhXcKoxn4w5SAgK3ZozFpwxf1whrx2BLQ== 61 | -----END EC PRIVATE KEY----- 62 | """ 63 | 64 | 65 | class SoftU2FDevice(object): 66 | 67 | """ 68 | This simulates the U2F browser API with a soft U2F device connected. 69 | It can be used for testing. 70 | """ 71 | def __init__(self): 72 | self.keys = {} 73 | self.counter = 0 74 | 75 | def register(self, facet, app_id, request): 76 | """ 77 | RegisterRequest = { 78 | "version": "U2F_V2", 79 | "challenge": string, //b64 encoded challenge 80 | "appId": string, //app_id 81 | } 82 | """ 83 | 84 | if not isinstance(request, RegisterRequest): 85 | request = RegisterRequest(request) 86 | 87 | if request.version != 'U2F_V2': 88 | raise ValueError('Unsupported U2F version: %s' % request.version) 89 | 90 | # Client data 91 | client_data = ClientData( 92 | typ=Type.REGISTER.value, 93 | challenge=request['challenge'], 94 | origin=facet 95 | ) 96 | client_data = client_data.json.encode('utf-8') 97 | client_param = sha_256(client_data) 98 | 99 | # ECC key generation 100 | priv_key = ec.generate_private_key(CURVE, default_backend()) 101 | pub_key = priv_key.public_key().public_bytes( 102 | Encoding.DER, PublicFormat.SubjectPublicKeyInfo) 103 | pub_key = pub_key[-65:] 104 | 105 | # Store 106 | key_handle = os.urandom(64) 107 | app_param = sha_256(app_id.encode('idna')) 108 | self.keys[key_handle] = (priv_key, app_param) 109 | 110 | # Attestation signature 111 | cert_priv = load_pem_private_key( 112 | CERT_PRIV, password=None, backend=default_backend()) 113 | cert = CERT 114 | data = b'\x00' + app_param + client_param + key_handle + pub_key 115 | signer = cert_priv.signer(ec.ECDSA(hashes.SHA256())) 116 | signer.update(data) 117 | signature = signer.finalize() 118 | 119 | raw_response = (b'\x05' + pub_key + six.int2byte(len(key_handle)) + 120 | key_handle + cert + signature) 121 | 122 | return RegisterResponse( 123 | version=request.version, 124 | registrationData=websafe_encode(raw_response), 125 | clientData=websafe_encode(client_data), 126 | ) 127 | 128 | def getAssertion(self, facet, app_id, challenge, key, touch_byte=1): 129 | """ 130 | signData = { 131 | 'version': "U2F_V2", 132 | 'challenge': websafe_encode(self.challenge), 133 | 'appId': self.binding.app_id, 134 | 'keyHandle': websafe_encode(self.binding.key_handle), 135 | } 136 | """ 137 | 138 | key = RegisteredKey.wrap(key) 139 | 140 | if key.version != 'U2F_V2': 141 | raise ValueError('Unsupported U2F version: %s' % key.version) 142 | 143 | if key.keyHandle not in self.keys: 144 | raise ValueError('Unknown key handle!') 145 | 146 | # Client data 147 | client_data = ClientData( 148 | typ=Type.SIGN.value, 149 | challenge=challenge, 150 | origin=facet 151 | ) 152 | client_data = client_data.json.encode('utf-8') 153 | client_param = sha_256(client_data) 154 | 155 | # Unwrap: 156 | priv_key, app_param = self.keys[key.keyHandle] 157 | 158 | # Increment counter 159 | self.counter += 1 160 | 161 | # Create signature 162 | touch = six.int2byte(touch_byte) 163 | counter = struct.pack('>I', self.counter) 164 | 165 | data = app_param + touch + counter + client_param 166 | signer = priv_key.signer(ec.ECDSA(hashes.SHA256())) 167 | signer.update(data) 168 | signature = signer.finalize() 169 | raw_response = touch + counter + signature 170 | 171 | return SignResponse( 172 | clientData=websafe_encode(client_data), 173 | signatureData=websafe_encode(raw_response), 174 | keyHandle=key['keyHandle'] 175 | ) 176 | -------------------------------------------------------------------------------- /test/test_api.py: -------------------------------------------------------------------------------- 1 | from u2fval import app, exc 2 | from u2fval.model import db, Client 3 | from .soft_u2f_v2 import SoftU2FDevice, CERT 4 | from six.moves.urllib.parse import quote 5 | from cryptography import x509 6 | from cryptography.hazmat.backends import default_backend 7 | from cryptography.hazmat.primitives.serialization import Encoding 8 | import unittest 9 | import json 10 | 11 | 12 | class RestApiTest(unittest.TestCase): 13 | 14 | def setUp(self): 15 | app.config['TESTING'] = True 16 | app.config['ALLOW_UNTRUSTED'] = True 17 | 18 | db.session.close() 19 | db.drop_all() 20 | db.create_all() 21 | db.session.add(Client('fooclient', 'https://example.com', 22 | ['https://example.com'])) 23 | db.session.commit() 24 | 25 | self.app = app.test_client() 26 | 27 | def test_call_without_client(self): 28 | resp = self.app.get('/') 29 | self.assertEqual(resp.status_code, 400) 30 | err = json.loads(resp.data.decode('utf8')) 31 | self.assertEqual(err['errorCode'], exc.BadInputException.code) 32 | 33 | def test_call_with_invalid_client(self): 34 | resp = self.app.get('/', environ_base={'REMOTE_USER': 'invalid'}) 35 | self.assertEqual(resp.status_code, 404) 36 | err = json.loads(resp.data.decode('utf8')) 37 | self.assertEqual(err['errorCode'], exc.BadInputException.code) 38 | 39 | def test_get_trusted_facets(self): 40 | resp = json.loads( 41 | self.app.get('/', environ_base={'REMOTE_USER': 'fooclient'} 42 | ).data.decode('utf8')) 43 | self.assertIn('https://example.com', resp['trustedFacets'][0]['ids']) 44 | 45 | def test_list_empty_devices(self): 46 | resp = json.loads( 47 | self.app.get('/foouser', environ_base={'REMOTE_USER': 'fooclient'} 48 | ).data.decode('utf8')) 49 | self.assertEqual(resp, []) 50 | 51 | def test_begin_auth_without_devices(self): 52 | resp = self.app.get('/foouser/sign', 53 | environ_base={'REMOTE_USER': 'fooclient'}) 54 | self.assertEqual(resp.status_code, 400) 55 | err = json.loads(resp.data.decode('utf8')) 56 | self.assertEqual(err['errorCode'], exc.NoEligibleDevicesException.code) 57 | 58 | def test_register(self): 59 | device = SoftU2FDevice() 60 | self.do_register(device, {'foo': 'bar'}) 61 | 62 | def test_sign(self): 63 | device = SoftU2FDevice() 64 | self.do_register(device, {'foo': 'bar', 'baz': 'one'}) 65 | descriptor = self.do_sign(device, {'baz': 'two'}) 66 | self.assertEqual(descriptor['properties'], 67 | {'foo': 'bar', 'baz': 'two'}) 68 | 69 | def test_get_properties(self): 70 | device = SoftU2FDevice() 71 | descriptor = self.do_register(device, {'foo': 'bar', 'baz': 'foo'}) 72 | descriptor2 = json.loads( 73 | self.app.get('/foouser/' + descriptor['handle'], 74 | environ_base={'REMOTE_USER': 'fooclient'} 75 | ).data.decode('utf8')) 76 | self.assertEqual(descriptor2['properties'], 77 | {'foo': 'bar', 'baz': 'foo'}) 78 | 79 | def test_update_properties(self): 80 | device = SoftU2FDevice() 81 | desc = self.do_register(device, 82 | {'foo': 'one', 'bar': 'one', 'baz': 'one'}) 83 | self.assertEqual({ 84 | 'foo': 'one', 85 | 'bar': 'one', 86 | 'baz': 'one' 87 | }, desc['properties']) 88 | 89 | desc2 = json.loads(self.app.post( 90 | '/foouser/' + desc['handle'], 91 | environ_base={'REMOTE_USER': 'fooclient'}, 92 | data=json.dumps({'bar': 'two', 'baz': None}) 93 | ).data.decode('utf8')) 94 | self.assertEqual({ 95 | 'foo': 'one', 96 | 'bar': 'two' 97 | }, desc2['properties']) 98 | 99 | desc3 = json.loads(self.app.get( 100 | '/foouser/' + desc['handle'], 101 | environ_base={'REMOTE_USER': 'fooclient'} 102 | ).data.decode('utf8')) 103 | self.assertEqual(desc2['properties'], desc3['properties']) 104 | 105 | def test_get_devices(self): 106 | self.do_register(SoftU2FDevice()) 107 | self.do_register(SoftU2FDevice()) 108 | self.do_register(SoftU2FDevice()) 109 | 110 | resp = json.loads( 111 | self.app.get('/foouser', environ_base={'REMOTE_USER': 'fooclient'} 112 | ).data.decode('utf8')) 113 | self.assertEqual(len(resp), 3) 114 | 115 | def test_get_device_descriptor_and_cert(self): 116 | desc = self.do_register(SoftU2FDevice()) 117 | 118 | desc2 = json.loads( 119 | self.app.get('/foouser/' + desc['handle'], 120 | environ_base={'REMOTE_USER': 'fooclient'} 121 | ).data.decode('utf8')) 122 | 123 | self.assertEqual(desc, desc2) 124 | 125 | cert = x509.load_pem_x509_certificate(self.app.get( 126 | '/foouser/' + desc['handle'] + '/certificate', 127 | environ_base={'REMOTE_USER': 'fooclient'} 128 | ).data, default_backend()) 129 | self.assertEqual(CERT, cert.public_bytes(Encoding.DER)) 130 | 131 | def test_get_invalid_device(self): 132 | resp = self.app.get('/foouser/' + ('ab' * 16), 133 | environ_base={'REMOTE_USER': 'fooclient'} 134 | ) 135 | self.assertEqual(resp.status_code, 404) 136 | 137 | self.do_register(SoftU2FDevice()) 138 | resp = self.app.get('/foouser/' + ('ab' * 16), 139 | environ_base={'REMOTE_USER': 'fooclient'} 140 | ) 141 | self.assertEqual(resp.status_code, 404) 142 | 143 | resp = self.app.get('/foouser/InvalidHandle', 144 | environ_base={'REMOTE_USER': 'fooclient'} 145 | ) 146 | self.assertEqual(resp.status_code, 400) 147 | 148 | def test_delete_user(self): 149 | self.do_register(SoftU2FDevice()) 150 | self.do_register(SoftU2FDevice()) 151 | self.do_register(SoftU2FDevice()) 152 | self.app.delete('/foouser', 153 | environ_base={'REMOTE_USER': 'fooclient'}) 154 | resp = json.loads( 155 | self.app.get('/foouser', environ_base={'REMOTE_USER': 'fooclient'} 156 | ).data.decode('utf8')) 157 | self.assertEqual(resp, []) 158 | 159 | def test_delete_devices(self): 160 | d1 = self.do_register(SoftU2FDevice()) 161 | d2 = self.do_register(SoftU2FDevice()) 162 | d3 = self.do_register(SoftU2FDevice()) 163 | 164 | self.app.delete('/foouser/' + d2['handle'], 165 | environ_base={'REMOTE_USER': 'fooclient'}) 166 | resp = json.loads( 167 | self.app.get('/foouser', 168 | environ_base={'REMOTE_USER': 'fooclient'} 169 | ).data.decode('utf8')) 170 | self.assertEqual(len(resp), 2) 171 | self.app.delete('/foouser/' + d1['handle'], 172 | environ_base={'REMOTE_USER': 'fooclient'}) 173 | resp = json.loads( 174 | self.app.get('/foouser', 175 | environ_base={'REMOTE_USER': 'fooclient'} 176 | ).data.decode('utf8')) 177 | self.assertEqual(len(resp), 1) 178 | self.assertEqual(d3, resp[0]) 179 | self.app.delete('/foouser/' + d3['handle'], 180 | environ_base={'REMOTE_USER': 'fooclient'}) 181 | resp = json.loads( 182 | self.app.get('/foouser', 183 | environ_base={'REMOTE_USER': 'fooclient'} 184 | ).data.decode('utf8')) 185 | self.assertEqual(resp, []) 186 | 187 | def test_set_properties_during_register(self): 188 | device = SoftU2FDevice() 189 | reg_req = json.loads(self.app.get( 190 | '/foouser/register?properties=' + quote(json.dumps( 191 | {'foo': 'one', 'bar': 'one'})), 192 | environ_base={'REMOTE_USER': 'fooclient'} 193 | ).data.decode('utf8')) 194 | 195 | reg_resp = device.register('https://example.com', reg_req['appId'], 196 | reg_req['registerRequests'][0]).json 197 | 198 | desc = json.loads(self.app.post( 199 | '/foouser/register', 200 | data=json.dumps({ 201 | 'registerResponse': reg_resp, 202 | 'properties': {'baz': 'two', 'bar': 'two'} 203 | }), 204 | environ_base={'REMOTE_USER': 'fooclient'} 205 | ).data.decode('utf8')) 206 | self.assertEqual({'foo': 'one', 'bar': 'two', 'baz': 'two'}, 207 | desc['properties']) 208 | 209 | def test_set_properties_during_sign(self): 210 | device = SoftU2FDevice() 211 | self.do_register(device, {'foo': 'one', 'bar': 'one', 'baz': 'one'}) 212 | 213 | aut_req = json.loads(self.app.get( 214 | '/foouser/sign?properties=' + quote(json.dumps( 215 | {'bar': 'two', 'boo': 'two'})), 216 | environ_base={'REMOTE_USER': 'fooclient'} 217 | ).data.decode('utf8')) 218 | aut_resp = device.getAssertion('https://example.com', aut_req['appId'], 219 | aut_req['challenge'], 220 | aut_req['registeredKeys'][0]).json 221 | desc = json.loads(self.app.post( 222 | '/foouser/sign', 223 | data=json.dumps({ 224 | 'signResponse': aut_resp, 225 | 'properties': {'baz': 'three', 'boo': None} 226 | }), 227 | environ_base={'REMOTE_USER': 'fooclient'} 228 | ).data.decode('utf8')) 229 | self.assertEqual({ 230 | 'foo': 'one', 231 | 'bar': 'two', 232 | 'baz': 'three', 233 | }, desc['properties']) 234 | 235 | def test_register_and_sign_with_custom_challenge(self): 236 | device = SoftU2FDevice() 237 | reg_req = json.loads(self.app.get( 238 | '/foouser/register?challenge=ThisIsAChallenge', 239 | environ_base={'REMOTE_USER': 'fooclient'} 240 | ).data.decode('utf8')) 241 | 242 | self.assertEqual(reg_req['registerRequests'][0]['challenge'], 243 | 'ThisIsAChallenge') 244 | reg_resp = device.register('https://example.com', reg_req['appId'], 245 | reg_req['registerRequests'][0]).json 246 | 247 | desc1 = json.loads(self.app.post( 248 | '/foouser/register', 249 | data=json.dumps({ 250 | 'registerResponse': reg_resp 251 | }), 252 | environ_base={'REMOTE_USER': 'fooclient'} 253 | ).data.decode('utf8')) 254 | 255 | aut_req = json.loads(self.app.get( 256 | '/foouser/sign?challenge=ThisIsAChallenge', 257 | environ_base={'REMOTE_USER': 'fooclient'} 258 | ).data.decode('utf8')) 259 | self.assertEqual(aut_req['challenge'], 'ThisIsAChallenge') 260 | aut_resp = device.getAssertion('https://example.com', aut_req['appId'], 261 | aut_req['challenge'], 262 | aut_req['registeredKeys'][0]).json 263 | desc2 = json.loads(self.app.post( 264 | '/foouser/sign', 265 | data=json.dumps({ 266 | 'signResponse': aut_resp 267 | }), 268 | environ_base={'REMOTE_USER': 'fooclient'} 269 | ).data.decode('utf8')) 270 | self.assertEqual(desc1['handle'], desc2['handle']) 271 | 272 | def test_sign_with_handle_filtering(self): 273 | dev = SoftU2FDevice() 274 | h1 = self.do_register(dev)['handle'] 275 | h2 = self.do_register(dev)['handle'] 276 | self.do_register(dev)['handle'] 277 | 278 | aut_req = json.loads( 279 | self.app.get('/foouser/sign', 280 | environ_base={'REMOTE_USER': 'fooclient'} 281 | ).data.decode('utf8')) 282 | self.assertEqual(len(aut_req['registeredKeys']), 3) 283 | self.assertEqual(len(aut_req['descriptors']), 3) 284 | 285 | aut_req = json.loads( 286 | self.app.get('/foouser/sign?handle=' + h1, 287 | environ_base={'REMOTE_USER': 'fooclient'} 288 | ).data.decode('utf8')) 289 | self.assertEqual(len(aut_req['registeredKeys']), 1) 290 | self.assertEqual(aut_req['descriptors'][0]['handle'], h1) 291 | 292 | aut_req = json.loads( 293 | self.app.get( 294 | '/foouser/sign?handle=' + h1 + '&handle=' + h2, 295 | environ_base={'REMOTE_USER': 'fooclient'} 296 | ).data.decode('utf8')) 297 | self.assertEqual(len(aut_req['registeredKeys']), 2) 298 | self.assertIn(aut_req['descriptors'][0]['handle'], [h1, h2]) 299 | self.assertIn(aut_req['descriptors'][1]['handle'], [h1, h2]) 300 | 301 | def test_sign_with_invalid_handle(self): 302 | dev = SoftU2FDevice() 303 | self.do_register(dev) 304 | 305 | resp = self.app.get('/foouser/sign?handle=foobar', 306 | environ_base={'REMOTE_USER': 'fooclient'}) 307 | self.assertEqual(resp.status_code, 400) 308 | 309 | def test_device_compromised_on_counter_error(self): 310 | dev = SoftU2FDevice() 311 | self.do_register(dev) 312 | self.do_sign(dev) 313 | self.do_sign(dev) 314 | self.do_sign(dev) 315 | dev.counter = 1 316 | 317 | aut_req = json.loads( 318 | self.app.get('/foouser/sign', 319 | environ_base={'REMOTE_USER': 'fooclient'} 320 | ).data.decode('utf8')) 321 | aut_resp = dev.getAssertion('https://example.com', aut_req['appId'], 322 | aut_req['challenge'], 323 | aut_req['registeredKeys'][0]).json 324 | resp = self.app.post( 325 | '/foouser/sign', 326 | data=json.dumps({ 327 | 'signResponse': aut_resp 328 | }), 329 | environ_base={'REMOTE_USER': 'fooclient'} 330 | ) 331 | 332 | self.assertEqual(400, resp.status_code) 333 | self.assertEqual(12, json.loads(resp.data.decode('utf8'))['errorCode']) 334 | 335 | resp = self.app.get('/foouser/sign', 336 | environ_base={'REMOTE_USER': 'fooclient'}) 337 | self.assertEqual(400, resp.status_code) 338 | self.assertEqual(11, json.loads(resp.data.decode('utf8'))['errorCode']) 339 | 340 | def do_register(self, device, properties=None): 341 | reg_req = json.loads( 342 | self.app.get('/foouser/register', 343 | environ_base={'REMOTE_USER': 'fooclient'} 344 | ).data.decode('utf8')) 345 | self.assertEqual(len(reg_req['registeredKeys']), 346 | len(reg_req['descriptors'])) 347 | 348 | reg_resp = device.register('https://example.com', reg_req['appId'], 349 | reg_req['registerRequests'][0]).json 350 | 351 | if properties is None: 352 | properties = {} 353 | descriptor = json.loads(self.app.post( 354 | '/foouser/register', 355 | data=json.dumps({ 356 | 'registerResponse': reg_resp, 357 | 'properties': properties 358 | }), 359 | environ_base={'REMOTE_USER': 'fooclient'} 360 | ).data.decode('utf8')) 361 | self.assertEqual(descriptor['properties'], properties) 362 | return descriptor 363 | 364 | def do_sign(self, device, properties=None): 365 | aut_req = json.loads( 366 | self.app.get('/foouser/sign', 367 | environ_base={'REMOTE_USER': 'fooclient'} 368 | ).data.decode('utf8')) 369 | aut_resp = device.getAssertion('https://example.com', aut_req['appId'], 370 | aut_req['challenge'], 371 | aut_req['registeredKeys'][0]).json 372 | if properties is None: 373 | properties = {} 374 | return json.loads(self.app.post( 375 | '/foouser/sign', 376 | data=json.dumps({ 377 | 'signResponse': aut_resp, 378 | 'properties': properties 379 | }), 380 | environ_base={'REMOTE_USER': 'fooclient'} 381 | ).data.decode('utf8')) 382 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py27 4 | py33 5 | py34 6 | py35 7 | pypy 8 | 9 | [testenv] 10 | develop = True 11 | deps = 12 | -r{toxinidir}/dev-requirements.txt 13 | commands = 14 | coverage run setup.py test 15 | coverage report 16 | coverage html 17 | -------------------------------------------------------------------------------- /u2fval/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from flask import Flask 29 | import os 30 | 31 | __version__ = '2.0.1-dev0' 32 | 33 | 34 | app = Flask(__name__) 35 | app.config.from_object('u2fval.default_settings') 36 | 37 | # If U2FVAL_SETTINGS is specified, load that file. Otherwise, load a file from 38 | # /etc/yubico/u2fval/ if it exists. 39 | silent = True 40 | conf_file = os.environ.get('U2FVAL_SETTINGS') 41 | if conf_file: 42 | silent = False 43 | if not os.path.isabs(conf_file): 44 | conf_file = os.path.join(os.getcwd(), conf_file) 45 | else: 46 | conf_file = '/etc/yubico/u2fval/u2fval.conf' 47 | 48 | app.config.from_pyfile(conf_file, silent=silent) 49 | 50 | # The previous version used DATABASE_CONFIGURATION. 51 | db_conn = app.config.get('DATABASE_CONFIGURATION') 52 | if db_conn is not None: 53 | app.logger.warn('The DATABASE_CONFIGURATION setting is deprecated, you ' 54 | 'should use SQLALCHEMY_DATABASE_URI instead!') 55 | app.config['SQLALCHEMY_DATABASE_URI'] = db_conn 56 | 57 | import u2fval.view # noqa 58 | import u2fval.model # noqa 59 | -------------------------------------------------------------------------------- /u2fval/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from wsgiref.simple_server import make_server 4 | from werkzeug.exceptions import NotFound 5 | from werkzeug.wsgi import pop_path_info 6 | from . import app 7 | from .model import db, Client 8 | from six.moves.urllib_parse import urlparse 9 | import os 10 | import re 11 | import sys 12 | import click 13 | 14 | 15 | NAME_PATTERN = re.compile(r'^[a-zA-Z0-9-_.]{3,}$') 16 | 17 | 18 | def ensure_valid_name(name): 19 | if len(name) < 3: 20 | raise ValueError('Client names must be at least 3 characters') 21 | if len(name) > 40: 22 | raise ValueError('Client names must be no longer than 40 characters') 23 | if not NAME_PATTERN.match(name): 24 | raise ValueError('Client names may only contain the characters a-z, ' 25 | 'A-Z, 0-9, "." (period), "_" (underscore), and "-" ' 26 | '(dash)') 27 | 28 | 29 | CLICK_CONTEXT_SETTINGS = dict( 30 | help_option_names=['-h', '--help'], 31 | max_content_width=999 32 | ) 33 | 34 | 35 | @click.group(context_settings=CLICK_CONTEXT_SETTINGS) 36 | @click.option('--config', help='Specify configuration file.') 37 | def cli(config): 38 | """ 39 | u2fval command line tool 40 | 41 | Specify a configuration file to use by setting the U2FVAL_SETTINGS 42 | environment variable (or use the --config option). 43 | 44 | Use u2fval COMMMAND --help for help on a specific command. 45 | """ 46 | if config: 47 | app.config.from_pyfile(os.path.abspath(config)) 48 | 49 | 50 | @cli.group('db') 51 | def database(): 52 | pass 53 | 54 | 55 | @database.command() 56 | def init(): 57 | """Initializes the database by creating the tables.""" 58 | db.create_all() 59 | click.echo('Database initialized!') 60 | 61 | 62 | @cli.group() 63 | def client(): 64 | pass 65 | 66 | 67 | @client.command('list') 68 | def _list(): 69 | """List the existing clients""" 70 | for c in Client.query.all(): 71 | click.echo(c.name) 72 | 73 | 74 | def _get_facets(ctx, appid, facets): 75 | if facets: 76 | return list(facets) 77 | url = urlparse(appid) 78 | if appid == '%s://%s' % (url.scheme, url.netloc): 79 | return [appid] 80 | ctx.fail('At least one facet is required unless appId is an origin') 81 | 82 | 83 | @client.command() 84 | @click.pass_context 85 | @click.argument('name') 86 | @click.argument('appId') 87 | @click.argument('facets', nargs=-1) 88 | def create(ctx, name, appid, facets): 89 | """ 90 | Create a new client 91 | 92 | If no FACETS are given and the APPID is a valid web origin, the APPID will 93 | be used as the only valid facet. 94 | """ 95 | ensure_valid_name(name) 96 | db.session.add(Client(name, appid, _get_facets(ctx, appid, facets))) 97 | db.session.commit() 98 | click.echo('Client created: %s' % name) 99 | 100 | 101 | @client.command() 102 | @click.argument('name') 103 | def show(name): 104 | """Display information about a client""" 105 | c = Client.query.filter(Client.name == name).one() 106 | click.echo('Client: %s' % c.name) 107 | click.echo('AppID: %s' % c.app_id) 108 | click.echo('FacetIDs:') 109 | for facet in c.valid_facets: 110 | click.echo(' %s' % facet) 111 | click.echo('Users: %d' % c.users.count()) 112 | 113 | 114 | @client.command(help='set the appId and valid facets for an existing client') 115 | @click.pass_context 116 | @click.argument('name') 117 | @click.argument('appId') 118 | @click.argument('facets', nargs=-1) 119 | def update(ctx, name, appid, facets): 120 | """Change the AppID and valid facets for a client""" 121 | c = Client.query.filter(Client.name == name).one() 122 | c.app_id = appid 123 | c.valid_facets = _get_facets(ctx, appid, facets) 124 | db.session.commit() 125 | click.echo('Client updated: %s' % name) 126 | 127 | 128 | @client.command() 129 | @click.argument('name') 130 | def delete(name): 131 | """Deletes a client""" 132 | c = Client.query.filter(Client.name == name).one() 133 | db.session.delete(c) 134 | db.session.commit() 135 | click.echo('Client deleted: %s' % name) 136 | 137 | 138 | def client_from_path(app): 139 | def inner(environ, start_response): 140 | client_name = pop_path_info(environ) 141 | if not client_name: 142 | return NotFound()(environ, start_response) 143 | 144 | environ['REMOTE_USER'] = client_name 145 | return app(environ, start_response) 146 | return inner 147 | 148 | 149 | @cli.command() 150 | @click.option('-i', '--interface', default='localhost', 151 | help='network interface to bind to') 152 | @click.option('-p', '--port', default=8080, help='port to bind to') 153 | @click.option('-c', '--client', help='run in single client mode') 154 | @click.option('-d', '--debug', is_flag=True, 155 | help='run the debug server in multi-client mode, using ' 156 | 'http://CLIENT@... to specify client, with no authentication.') 157 | def run(interface, port, client, debug): 158 | """Runs a U2FVAL server""" 159 | if debug: 160 | app.config['DEBUG'] = True 161 | click.echo('Starting debug server on http://%s:%d...' % ( 162 | interface, port)) 163 | return app.run(interface, port, debug) 164 | 165 | application = app 166 | extra_environ = {} 167 | if client: 168 | Client.query.filter(Client.name == client).one() 169 | click.echo("Running in single-client mode for client: '%s'" % client) 170 | extra_environ['REMOTE_USER'] = client 171 | else: 172 | click.echo('Running in multi-client mode with client specified in the ' 173 | 'path') 174 | application = client_from_path(app) 175 | 176 | httpd = make_server(interface, port, application) 177 | httpd.base_environ.update(extra_environ) 178 | click.echo('Starting server on http://%s:%d...' % (interface, port)) 179 | return httpd.serve_forever() 180 | 181 | 182 | def main(): 183 | try: 184 | cli(obj={}) 185 | except ValueError as e: 186 | print('Error:', e) 187 | return 1 188 | 189 | 190 | if __name__ == '__main__': 191 | sys.exit(main()) 192 | -------------------------------------------------------------------------------- /u2fval/core/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /u2fval/default_settings.py: -------------------------------------------------------------------------------- 1 | # U2FVAL settings file 2 | 3 | # For testing/debugging only, set to False in production! 4 | DEBUG = True 5 | TESTING = True 6 | 7 | # Set to False to disable pretty-printing of JSON responses. 8 | # Note: XMLHttpRequests are never pretty-printed. 9 | JSONIFY_PRETTYPRINT_REGULAR = True 10 | 11 | # Database configuration 12 | SQLALCHEMY_DATABASE_URI = 'sqlite://' 13 | SQLALCHEMY_TRACK_MODIFICATIONS = False 14 | 15 | # If True, use memcached for storing registration and authentication requests 16 | # in progress, instead of persisting them to the database. 17 | USE_MEMCACHED = False 18 | 19 | # If memcached is enabled, use these servers. 20 | MEMCACHED_SERVERS = ['127.0.0.1:11211'] 21 | 22 | # Add files containing trusted metadata JSON to the directory below. 23 | METADATA = '/etc/yubico/u2fval/metadata/' 24 | 25 | # Allow the use of untrusted (for which attestation cannot be verified using 26 | # the available trusted metadata) U2F devices. 27 | ALLOW_UNTRUSTED = False 28 | -------------------------------------------------------------------------------- /u2fval/exc.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | __all__ = [ 29 | 'U2fException', 30 | 'BadInputException', 31 | 'NoEligibleDevicesException', 32 | 'DeviceCompromisedException' 33 | ] 34 | 35 | 36 | class U2fException(Exception): 37 | status_code = 400 38 | code = -1 39 | 40 | def __init__(self, message, data=None): 41 | super(U2fException, self).__init__(message, data) 42 | self.message = message 43 | self.data = data 44 | 45 | 46 | class BadInputException(U2fException): 47 | code = 10 48 | 49 | 50 | class NotFoundException(BadInputException): 51 | status_code = 404 52 | 53 | 54 | class NoEligibleDevicesException(U2fException): 55 | code = 11 56 | 57 | 58 | class DeviceCompromisedException(U2fException): 59 | code = 12 60 | -------------------------------------------------------------------------------- /u2fval/jsobjects.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from u2flib_server.model import (JSONDict, RegisterResponse, SignResponse, 29 | U2fRegisterRequest, U2fSignRequest) 30 | 31 | __all__ = [ 32 | 'RegisterRequestData', 33 | 'RegisterResponseData', 34 | 'SignResponseData', 35 | 'SignResponseData' 36 | ] 37 | 38 | 39 | class WithProps(object): 40 | 41 | @property 42 | def properties(self): 43 | return self.get('properties', {}) 44 | 45 | 46 | class WithDescriptors(object): 47 | 48 | @property 49 | def descriptors(self): 50 | return [JSONDict.wrap(x) for x in self['descriptors']] 51 | 52 | 53 | class RegisterRequestData(U2fRegisterRequest, WithDescriptors): 54 | pass 55 | 56 | 57 | class RegisterResponseData(JSONDict, WithProps): 58 | _required_fields = ['registerResponse'] 59 | 60 | @property 61 | def registerResponse(self): 62 | return RegisterResponse.wrap(self['registerResponse']) 63 | 64 | @classmethod 65 | def wrap(cls, data): 66 | try: 67 | return super(RegisterResponseData, cls).wrap(data) 68 | except ValueError: 69 | response = RegisterResponse.wrap(data) 70 | return cls(registerResponse=response.json) 71 | 72 | 73 | class SignRequestData(U2fSignRequest, WithDescriptors): 74 | pass 75 | 76 | 77 | class SignResponseData(JSONDict, WithProps): 78 | _required_fields = ['signResponse'] 79 | 80 | @property 81 | def signResponse(self): 82 | return SignResponse.wrap(self['signResponse']) 83 | 84 | @classmethod 85 | def wrap(cls, data): 86 | try: 87 | return super(SignResponseData, cls).wrap(data) 88 | except ValueError: 89 | response = SignResponse.wrap(data) 90 | return cls(signResponse=response.json) 91 | -------------------------------------------------------------------------------- /u2fval/model.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from . import app 4 | from u2flib_server.model import Transport 5 | from flask_sqlalchemy import SQLAlchemy 6 | from sqlalchemy.orm.collections import attribute_mapped_collection 7 | from sqlalchemy.ext.hybrid import hybrid_property 8 | from sqlalchemy.ext.associationproxy import association_proxy 9 | from cryptography import x509 10 | from cryptography.hazmat.backends import default_backend 11 | from cryptography.hazmat.primitives import hashes 12 | from cryptography.hazmat.primitives.serialization import Encoding 13 | from base64 import b64encode, b64decode 14 | from binascii import b2a_hex 15 | from datetime import datetime 16 | import json 17 | import os 18 | 19 | 20 | db = SQLAlchemy(app) 21 | 22 | 23 | class Client(db.Model): 24 | __tablename__ = 'clients' 25 | 26 | id = db.Column(db.Integer, db.Sequence('client_id_seq'), primary_key=True) 27 | name = db.Column(db.String(40), nullable=False, unique=True) 28 | app_id = db.Column(db.String(256), nullable=False) 29 | _valid_facets = db.Column('valid_facets', db.Text(), default='[]') 30 | 31 | def __init__(self, name, app_id, facets): 32 | self.name = name 33 | self.app_id = app_id 34 | self.valid_facets = facets 35 | 36 | @hybrid_property 37 | def valid_facets(self): 38 | return json.loads(self._valid_facets) 39 | 40 | @valid_facets.setter 41 | def valid_facets(self, facets): 42 | if not isinstance(facets, list): 43 | raise TypeError('facets must be a list') 44 | self._valid_facets = json.dumps(facets) 45 | 46 | 47 | def _calculate_fingerprint(cert): 48 | return b2a_hex(cert.fingerprint(hashes.SHA256())).decode('ascii') 49 | 50 | 51 | class User(db.Model): 52 | __tablename__ = 'users' 53 | __table_args__ = (db.UniqueConstraint('client_id', 'name', 54 | name='_client_user_uc'),) 55 | 56 | id = db.Column(db.Integer, db.Sequence('user_id_seq'), primary_key=True) 57 | name = db.Column(db.String(40), nullable=False) 58 | client_id = db.Column(db.Integer, db.ForeignKey('clients.id')) 59 | client = db.relationship(Client, 60 | backref=db.backref('users', lazy='dynamic')) 61 | devices = db.relationship( 62 | 'Device', 63 | backref='user', 64 | order_by='Device.handle', 65 | collection_class=attribute_mapped_collection('handle'), 66 | cascade='all, delete-orphan' 67 | ) 68 | transactions = db.relationship( 69 | 'Transaction', 70 | backref='user', 71 | order_by='Transaction.created_at.desc()', 72 | lazy='dynamic', 73 | cascade='all, delete-orphan') 74 | 75 | def __init__(self, name): 76 | self.name = name 77 | 78 | def add_device(self, bind_data, cert_der, transports=0): 79 | cert = x509.load_der_x509_certificate(cert_der, default_backend()) 80 | certificate = db.session.query(Certificate) \ 81 | .filter(Certificate.fingerprint == _calculate_fingerprint(cert)) \ 82 | .first() 83 | if certificate is None: 84 | certificate = Certificate(cert) 85 | return Device(self, bind_data, certificate, transports) 86 | 87 | 88 | class Certificate(db.Model): 89 | __tablename__ = 'certificates' 90 | 91 | id = db.Column(db.Integer, db.Sequence('certificate_id_seq'), 92 | primary_key=True) 93 | # The fingerprint field is larger than needed, to accomodate longer 94 | # fingerprints in the future. 95 | fingerprint = db.Column(db.String(128), nullable=False, unique=True) 96 | _der = db.Column('der', db.Text(), nullable=False) 97 | 98 | @hybrid_property 99 | def der(self): 100 | return b64decode(self._der) 101 | 102 | @der.setter 103 | def der(self, der): 104 | self._der = b64encode(der) 105 | 106 | def __init__(self, cert): 107 | self.fingerprint = _calculate_fingerprint(cert) 108 | self.der = cert.public_bytes(Encoding.DER) 109 | 110 | def get_pem(self): 111 | cert = x509.load_der_x509_certificate(self.der, default_backend()) 112 | return cert.public_bytes(Encoding.PEM) 113 | 114 | 115 | class Device(db.Model): 116 | __tablename__ = 'devices' 117 | 118 | id = db.Column(db.Integer, db.Sequence('device_id_seq'), primary_key=True) 119 | handle = db.Column(db.String(32), nullable=False, unique=True) 120 | user_id = db.Column(db.Integer, db.ForeignKey('users.id')) 121 | bind_data = db.Column(db.Text()) 122 | certificate_id = db.Column(db.Integer, db.ForeignKey('certificates.id')) 123 | certificate = db.relationship('Certificate') 124 | compromised = db.Column(db.Boolean, default=False) 125 | created_at = db.Column(db.DateTime, default=datetime.utcnow) 126 | authenticated_at = db.Column(db.DateTime) 127 | counter = db.Column(db.BigInteger) 128 | transports = db.Column(db.BigInteger) 129 | _properties = db.relationship( 130 | 'Property', 131 | backref='device', 132 | order_by='Property.key', 133 | collection_class=attribute_mapped_collection('key'), 134 | cascade='all, delete-orphan' 135 | ) 136 | properties = association_proxy( 137 | '_properties', 138 | 'value', 139 | creator=lambda k, v: Property(k, v) 140 | ) 141 | 142 | def __init__(self, user, bind_data, certificate, transports=0): 143 | self.handle = b2a_hex(os.urandom(16)).decode('ascii') 144 | self.bind_data = bind_data 145 | self.user = user 146 | self.certificate = certificate 147 | self.transports = transports 148 | 149 | def update_properties(self, props): 150 | for k, v in props.items(): 151 | if v is None: 152 | del self.properties[k] 153 | else: 154 | self.properties[k] = v 155 | 156 | def get_descriptor(self, metadata=None): 157 | authenticated = self.authenticated_at 158 | if authenticated is not None: 159 | authenticated = authenticated.isoformat() + 'Z' 160 | 161 | transports = [t.key for t in Transport if t.value & self.transports] 162 | data = { 163 | 'handle': self.handle, 164 | 'transports': transports, 165 | 'compromised': self.compromised, 166 | 'created': self.created_at.isoformat() + 'Z', 167 | 'lastUsed': authenticated, 168 | 'properties': dict(self.properties) 169 | } 170 | 171 | if metadata is not None: 172 | data['metadata'] = metadata 173 | 174 | return data 175 | 176 | 177 | class Property(db.Model): 178 | __tablename__ = 'properties' 179 | 180 | id = db.Column(db.Integer, db.Sequence('property_id_seq'), 181 | primary_key=True) 182 | key = db.Column(db.String(40)) 183 | value = db.Column(db.Text()) 184 | device_id = db.Column(db.Integer, db.ForeignKey('devices.id')) 185 | 186 | def __init__(self, key, value): 187 | self.key = key 188 | self.value = value 189 | 190 | 191 | class Transaction(db.Model): 192 | __tablename__ = 'transactions' 193 | 194 | id = db.Column(db.Integer, db.Sequence('transaction_id_seq'), 195 | primary_key=True) 196 | user_id = db.Column(db.Integer, db.ForeignKey('users.id')) 197 | transaction_id = db.Column(db.String(64), nullable=False, unique=True) 198 | _data = db.Column(db.Text()) 199 | created_at = db.Column(db.DateTime, default=datetime.utcnow) 200 | 201 | def __init__(self, transaction_id, data): 202 | self.transaction_id = transaction_id 203 | self.data = data 204 | 205 | @hybrid_property 206 | def data(self): 207 | return json.loads(self._data) 208 | 209 | @data.setter 210 | def data(self, value): 211 | self._data = json.dumps(value) 212 | -------------------------------------------------------------------------------- /u2fval/transactiondb.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from __future__ import absolute_import 29 | 30 | from .model import db, User, Transaction 31 | from u2flib_server.utils import sha_256 32 | from datetime import datetime, timedelta 33 | from binascii import b2a_hex 34 | 35 | 36 | class DBStore(object): 37 | 38 | def __init__(self, max_transactions=5, ttl=300): 39 | self._max_transactions = max_transactions 40 | self._ttl = ttl 41 | 42 | def _delete_expired(self): 43 | expiration = datetime.utcnow() - timedelta(seconds=self._ttl) 44 | Transaction.query \ 45 | .filter(Transaction.created_at < expiration).delete() 46 | 47 | def store(self, client_id, user_id, transaction_id, data): 48 | transaction_id = b2a_hex(sha_256(transaction_id)) 49 | user = User.query \ 50 | .filter(User.client_id == client_id) \ 51 | .filter(User.name == user_id).first() 52 | if user is None: 53 | user = User(user_id) 54 | user.client_id = client_id 55 | db.session.add(user) 56 | else: 57 | self._delete_expired() 58 | # Delete oldest transactions until we have room for one more. 59 | for transaction in user.transactions \ 60 | .offset(self._max_transactions - 1).all(): 61 | db.session.delete(transaction) 62 | user.transactions.append(Transaction(transaction_id, data)) 63 | db.session.commit() 64 | 65 | def retrieve(self, client_id, user_id, transaction_id): 66 | transaction_id = b2a_hex(sha_256(transaction_id)) 67 | self._delete_expired() 68 | transaction = Transaction.query \ 69 | .filter(Transaction.transaction_id == transaction_id).first() 70 | if transaction is None: 71 | raise ValueError('Invalid transaction') 72 | if transaction.user.name != user_id or \ 73 | transaction.user.client_id != client_id: 74 | raise ValueError('Transaction not valid for user_id: %s' 75 | % user_id) 76 | db.session.delete(transaction) 77 | db.session.commit() 78 | return transaction.data 79 | -------------------------------------------------------------------------------- /u2fval/view.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from . import app, exc 4 | from .model import db, Client, User 5 | from .transactiondb import DBStore 6 | from flask import g, request, jsonify 7 | from werkzeug.contrib.cache import SimpleCache, MemcachedCache 8 | from u2flib_server.utils import websafe_decode 9 | from u2flib_server.u2f import (begin_registration, complete_registration, 10 | begin_authentication, complete_authentication) 11 | from u2flib_server.attestation import MetadataProvider, create_resolver 12 | from .jsobjects import (RegisterRequestData, RegisterResponseData, 13 | SignRequestData, SignResponseData) 14 | from datetime import datetime 15 | from hashlib import sha256 16 | from six.moves.urllib.parse import unquote 17 | import json 18 | import os 19 | import re 20 | 21 | 22 | if app.config['USE_MEMCACHED']: 23 | cache = MemcachedCache(app.config['MEMCACHED_SERVERS']) 24 | else: 25 | cache = SimpleCache() 26 | 27 | 28 | store = DBStore() 29 | 30 | 31 | def create_metadata_provider(location): 32 | if os.path.isfile(location) \ 33 | or (os.path.isdir(location) and os.listdir(location)): 34 | resolver = create_resolver(location) 35 | else: 36 | resolver = None 37 | return MetadataProvider(resolver) 38 | 39 | 40 | metadata = create_metadata_provider(app.config.get('METADATA')) 41 | 42 | 43 | def get_attestation(cert): 44 | key = sha256(cert).hexdigest() 45 | attestation = cache.get(key) 46 | if attestation is None: 47 | attestation = metadata.get_attestation(cert) or '' # Cache "missing" 48 | cache.set(key, attestation, timeout=0) 49 | return attestation 50 | 51 | 52 | def get_metadata(dev): 53 | key = 'cert_metadata/%d' % dev.certificate_id 54 | data = cache.get(key) 55 | if data is None: 56 | data = {} 57 | attestation = get_attestation(dev.certificate.der) 58 | if attestation: 59 | if attestation.vendor_info: 60 | data['vendor'] = attestation.vendor_info 61 | if attestation.device_info: 62 | data['device'] = attestation.device_info 63 | cache.set(key, data, timeout=0) 64 | return data 65 | 66 | 67 | def get_client(): 68 | client = getattr(g, 'client', None) 69 | if client is None: 70 | name = request.environ.get('REMOTE_USER') 71 | if name is None and app.debug and request.authorization: 72 | name = request.authorization.username 73 | if name is None: 74 | raise exc.BadInputException('No client specified') 75 | try: 76 | g.client = client = Client.query.filter(Client.name == name).one() 77 | except: 78 | raise exc.NotFoundException('Client not found') 79 | return client 80 | 81 | 82 | def get_user(user_id): 83 | return get_client().users.filter(User.name == user_id).first() 84 | 85 | 86 | # Exception handling 87 | 88 | 89 | @app.errorhandler(400) 90 | def handle_bad_request(error): 91 | resp = jsonify({ 92 | 'errorCode': exc.BadInputException.code, 93 | 'errorMessage': error.description 94 | }) 95 | resp.status_code = error.code 96 | return resp 97 | 98 | 99 | @app.errorhandler(ValueError) 100 | def handle_value_error(error): 101 | resp = jsonify({ 102 | 'errorCode': exc.BadInputException.code, 103 | 'errorMessage': str(error) 104 | }) 105 | resp.status_code = 400 106 | return resp 107 | 108 | 109 | @app.errorhandler(exc.U2fException) 110 | def handle_http_exception(error): 111 | resp = jsonify({ 112 | 'errorCode': error.code, 113 | 'errorMessage': error.message 114 | }) 115 | resp.status_code = error.status_code 116 | return resp 117 | 118 | 119 | # Request handling 120 | 121 | 122 | @app.route('/') 123 | def trusted_facets(): 124 | client = get_client() 125 | return jsonify({ 126 | 'trustedFacets': [{ 127 | 'version': {'major': 1, 'minor': 0}, 128 | 'ids': client.valid_facets 129 | }] 130 | }) 131 | 132 | 133 | @app.route('/', methods=['GET', 'DELETE'], strict_slashes=False) 134 | def user(user_id): 135 | user = get_user(user_id) 136 | if request.method == 'DELETE': 137 | if user: 138 | app.logger.info('Delete user: "%s/%s"', user.client.name, 139 | user.name) 140 | db.session.delete(user) 141 | db.session.commit() 142 | return ('', 204) 143 | else: 144 | if user is not None: 145 | descriptors = [d.get_descriptor(get_metadata(d)) 146 | for d in user.devices.values()] 147 | else: 148 | descriptors = [] 149 | return jsonify(descriptors) 150 | 151 | 152 | def _get_registered_key(dev, descriptor): 153 | key = json.loads(dev.bind_data) 154 | # Only keep appId if different from the "main" one. 155 | if key.get('appId') == get_client().app_id: 156 | del key['appId'] 157 | # The 'version' field used to be missing in RegisteredKey. 158 | if 'version' not in key: 159 | key['version'] = 'U2F_V2' 160 | # Use transports from descriptor (which includes metadata) 161 | key['transports'] = descriptor['transports'] 162 | 163 | return key 164 | 165 | 166 | def _register_request(user_id, challenge, properties): 167 | client = get_client() 168 | user = get_user(user_id) 169 | registered_keys = [] 170 | descriptors = [] 171 | if user is not None: 172 | for dev in user.devices.values(): 173 | descriptor = dev.get_descriptor(get_metadata(dev)) 174 | descriptors.append(descriptor) 175 | key = _get_registered_key(dev, descriptor) 176 | registered_keys.append(key) 177 | request_data = begin_registration( 178 | client.app_id, 179 | registered_keys, 180 | challenge 181 | ) 182 | request_data['properties'] = properties 183 | store.store(client.id, user_id, challenge, request_data.json) 184 | 185 | data = RegisterRequestData.wrap(request_data.data_for_client) 186 | data['descriptors'] = descriptors 187 | return data 188 | 189 | 190 | def _register_response(user_id, response_data): 191 | client = get_client() 192 | user = get_user(user_id) 193 | register_response = response_data.registerResponse 194 | challenge = register_response.clientData.challenge 195 | request_data = store.retrieve(client.id, user_id, challenge) 196 | if request_data is None: 197 | raise exc.NotFoundException('Transaction not found') 198 | request_data = json.loads(request_data) 199 | registration, cert = complete_registration( 200 | request_data, register_response, client.valid_facets) 201 | attestation = get_attestation(cert) 202 | if not app.config['ALLOW_UNTRUSTED'] and not attestation.trusted: 203 | raise exc.BadInputException('Device attestation not trusted') 204 | if user is None: 205 | app.logger.info('Creating user: %s/%s', client.name, user_id) 206 | user = User(user_id) 207 | client.users.append(user) 208 | transports = sum(t.value for t in attestation.transports or []) 209 | dev = user.add_device(registration.json, cert, transports) 210 | # Properties from the initial request have a lower precedence. 211 | dev.update_properties(request_data['properties']) 212 | dev.update_properties(response_data.properties) 213 | db.session.commit() 214 | app.logger.info('Registered device: %s/%s/%s', client.name, user_id, 215 | dev.handle) 216 | return dev.get_descriptor(get_metadata(dev)) 217 | 218 | 219 | @app.route('//register', methods=['GET', 'POST']) 220 | def register(user_id): 221 | if request.method == 'POST': 222 | # Response 223 | return jsonify(_register_response( 224 | user_id, RegisterResponseData.wrap(request.get_json(force=True)))) 225 | else: 226 | # Request 227 | challenge = request.args.get('challenge', type=websafe_decode) 228 | if challenge is None: 229 | challenge = os.urandom(32) 230 | properties = request.args.get('properties', {}, 231 | lambda x: json.loads(unquote(x))) 232 | 233 | return jsonify(_register_request(user_id, challenge, properties)) 234 | 235 | 236 | def _sign_request(user_id, challenge, handles, properties): 237 | client = get_client() 238 | user = get_user(user_id) 239 | if user is None or len(user.devices) == 0: 240 | app.logger.info('User "%s" has no devices registered', user_id) 241 | raise exc.NoEligibleDevicesException('No devices registered', []) 242 | 243 | registered_keys = [] 244 | descriptors = [] 245 | handle_map = {} 246 | 247 | if not handles: 248 | handles = user.devices.keys() 249 | 250 | for handle in handles: 251 | try: 252 | dev = user.devices[handle] 253 | except KeyError: 254 | raise exc.BadInputException('Invalid device handle: ' + handle) 255 | if not dev.compromised: 256 | descriptor = dev.get_descriptor(get_metadata(dev)) 257 | descriptors.append(descriptor) 258 | key = _get_registered_key(dev, descriptor) 259 | registered_keys.append(key) 260 | handle_map[key['keyHandle']] = dev.handle 261 | 262 | if not registered_keys: 263 | raise exc.NoEligibleDevicesException( 264 | 'All devices compromised', 265 | [d.get_descriptor() for d in user.devices.values()] 266 | ) 267 | 268 | request_data = begin_authentication( 269 | client.app_id, 270 | registered_keys, 271 | challenge 272 | ) 273 | request_data['handleMap'] = handle_map 274 | request_data['properties'] = properties 275 | 276 | store.store(client.id, user_id, challenge, request_data.json) 277 | data = SignRequestData.wrap(request_data.data_for_client) 278 | data['descriptors'] = descriptors 279 | return data 280 | 281 | 282 | def _sign_response(user_id, response_data): 283 | client = get_client() 284 | user = get_user(user_id) 285 | sign_response = response_data.signResponse 286 | challenge = sign_response.clientData.challenge 287 | request_data = store.retrieve(client.id, user_id, challenge) 288 | if request_data is None: 289 | raise exc.NotFoundException('Transaction not found') 290 | request_data = json.loads(request_data) 291 | device, counter, presence = complete_authentication( 292 | request_data, sign_response, client.valid_facets) 293 | dev = user.devices[request_data['handleMap'][device['keyHandle']]] 294 | if dev.compromised: 295 | raise exc.DeviceCompromisedException('Device is compromised', 296 | dev.get_descriptor()) 297 | if presence == 0: 298 | raise exc.BadInputException('User presence byte not set') 299 | if counter > (dev.counter or -1): 300 | dev.counter = counter 301 | dev.authenticated_at = datetime.now() 302 | dev.update_properties(request_data['properties']) 303 | dev.update_properties(response_data.properties) 304 | db.session.commit() 305 | return dev.get_descriptor(get_metadata(dev)) 306 | else: 307 | dev.compromised = True 308 | db.session.commit() 309 | raise exc.DeviceCompromisedException('Device counter mismatch', 310 | dev.get_descriptor()) 311 | 312 | 313 | @app.route('//sign', methods=['GET', 'POST']) 314 | def sign(user_id): 315 | if request.method == 'POST': 316 | # Response 317 | return jsonify(_sign_response( 318 | user_id, SignResponseData.wrap(request.get_json(force=True)))) 319 | else: 320 | # Request 321 | challenge = request.args.get('challenge', type=websafe_decode) 322 | if challenge is None: 323 | challenge = os.urandom(32) 324 | properties = request.args.get('properties', {}, 325 | lambda x: json.loads(unquote(x))) 326 | handles = request.args.getlist('handle') 327 | 328 | return jsonify(_sign_request(user_id, challenge, handles, properties)) 329 | 330 | 331 | _HANDLE_PATTERN = re.compile(r'^[a-f0-9]{32}$') 332 | 333 | 334 | @app.route('//', methods=['GET', 'POST', 'DELETE']) 335 | def device(user_id, handle): 336 | if _HANDLE_PATTERN.match(handle) is None: 337 | raise exc.BadInputException('Invalid device handle: ' + handle) 338 | 339 | user = get_user(user_id) 340 | try: 341 | dev = user.devices[handle] 342 | except (AttributeError, KeyError): 343 | raise exc.NotFoundException('Device not found') 344 | 345 | if request.method == 'DELETE': 346 | if dev is not None: 347 | app.logger.info('Delete handle: %s/%s/%s', user.client.name, 348 | user.name, handle) 349 | db.session.delete(dev) 350 | db.session.commit() 351 | return ('', 204) 352 | elif request.method == 'POST': 353 | if dev is None: 354 | raise exc.NotFoundException('Device not found') 355 | dev.update_properties(request.get_json(force=True)) 356 | db.session.commit() 357 | else: 358 | if dev is None: 359 | raise exc.NotFoundException('Device not found') 360 | return jsonify(dev.get_descriptor(get_metadata(dev))) 361 | 362 | 363 | @app.route('///certificate') 364 | def device_certificate(user_id, handle): 365 | user = get_user(user_id) 366 | if user is None: 367 | raise exc.NotFoundException('Device not found') 368 | try: 369 | dev = user.devices[handle] 370 | except KeyError: 371 | raise exc.BadInputException('Invalid device handle: ' + handle) 372 | return dev.certificate.get_pem() 373 | --------------------------------------------------------------------------------