Initial commit
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..e1f036e
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,6 @@
+[run]
+branch = True
+omit =
+ */tests/*
+ .tox/*
+ servers/*/config_default.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..68d56e7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+*.pyc
+.cache
+.coverage
+.tox
+backup.json
+backup_dta.json
+config.py
+coverage.xml
+credentials.json
+M_Pin_Backend.egg-info/
+mpin_*_storage.json
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c69698a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..76f3295
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,5 @@
+Milagro MFA Server
+Copyright 2015 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f324e2d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,176 @@
+# Milagro MFA Services
+
+## General
+
+This repository contains all the services code required to build and deploy the *Milagro MFA Server*.
+This README describes the process of building and running the services on your **Ubuntu** machine.
+For other Linux distributions the process would be similar, while some steps as installation of
+required packages, should be adjusted for the specific platform.
+
+## Building and Installation
+
+### Cloning the code from the Git repository
+
+Using standard Git client, clone the code from this repository to a local one.
+The rest of this document assumes that the repository is cloned into `<mpin-backend>`.
+Wherever `<mpin-backend>` appears, it should be replaced with the real location on your machine.
+
+### Installing Prerequisites
+
+**1** Update your package manager tool
+```
+ > sudo apt-get update
+```
+**2** Install the following packages as shown below
+```
+ > sudo apt-get install python-dev python-pip libffi-dev
+ > sudo pip install -r requirements/common.txt
+```
+
+### Building the Milagro Crypto Libraries
+
+Clone the [_milagro-crypto_ repository](https://github.com/miracl/milagro-crypto) and checkout tag `1.0.0`.
+```
+> git clone https://github.com/miracl/milagro-crypto.git
+> git checkout tags/1.0.0
+```
+Follow the instructions for your platform from the milagro-crypto [README file](https://github.com/miracl/milagro-crypto/blob/master/README.md#build-instructions).
+
+### Getting Credentials
+
+Before running any Milagro Services, you should obtain your *Credentials*.
+This is done with the following script:
+```
+ > cd <mpin-backend>
+ > python scripts/getCommunityCredentials.py .
+```
+*NOTE:* Make sure you don't miss the dot (.) at the end of the above command.<br/>
+**Important:** During the above process you will be asked to enter your e-mail address. While this is not mandatory, it is recommended so we can later contact you in case of any problems with the service.
+The above script will download a `credentials.json` file into the `<mpin-backend>` directory.
+
+### Configuring the Services
+
+The Milagro MFA Services consist of: *Distributed Trusted Authority (D-TA)* and *Relying Party Service (RPS)*.
+The Web Application that integrates with the MFA in order to be able to log-in users using Milagro, is called *Relying Party Application (RPA)*.
+The installation, and the source code itself include a *Demo RPA*, which should be eventually replaced by the customer specific application.
+The initial configuration allows the Demo RPA and the Milagro MFA Services to be access from any machine on the local network.
+Further details about configuration options might be found in the [Documentation](http://docs.miracl.com/m-pin-core-configuration).
+
+#### Configuring the D-TA
+
+The source includes a "default" D-TA configuration file that should serve as a template for the actual one. To configure the D-TA perform the following steps:<br/>
+**1** Go to the directory `<mpin-backend>/servers` and copy the `dta/config_default.py` to `dta/config.py`
+```
+ > cd <mpin-backend>/servers
+ > cp dta/config_default.py dta/config.py
+```
+**2** Generate a hex-encoded 8-byte (or longer) random number. You can do this with the following command:
+```
+ > python -c "import os; print os.urandom(8).encode('hex')"
+ 038b9f0756b2a2c4
+```
+**3** Using `vi` or other editor, edit the `dta/config.py` file<br/>
+**4** Change the `salt` parameter, setting its value to the random value that was just generated:
+```
+salt = "038b9f0756b2a2c4"
+```
+**5** Add/change the `credentialsFile` parameter, specifying the location for the credential file that was previously obtained:
+```
+credentialsFile = "<mpin-backend>/credentials.json"
+```
+**6** Change the value of the `backup_file` parameter to the path to file where the master secret will be backed up.<br/>
+```
+backup_file = "<mpin-backend>/backup_dta.json"
+```
+**7** Change the value of the `passphrase` parameter as well. You might generate a random string for it as well, or write some phrase of your own.<br/>
+**8** Save the file and exit the editor
+
+#### Configuring the RPS
+
+The source includes a "default" RPS configuration file that should serve as a template for the actual one. To configure the RPS perform the following steps:<br/>
+**1** Go to the directory `<mpin-backend>/servers` and copy the `rps/config_default.py` to `rps/config.py`
+```
+ > cd <mpin-backend>/servers
+ > cp rps/config_default.py rps/config.py
+```
+**2** Using `vi` or other editor, edit the `rps/config.py` file<br/>
+**3** Add/change the `credentialsFile` parameter, specifying the location for the credential file that was previously obtained:
+```
+credentialsFile = "<mpin-backend>/credentials.json"
+```
+**4** Save the file and exit the editor
+
+#### Configuring the Demo RPA
+
+The source includes a "default" Demo RPA configuration file. To configure the Demo RPA perform the following steps:<br/>
+**1** Go to the directory `<mpin-backend>/servers` and copy the `demo/config_default.py` to `demo/config.py`
+```
+ > cd <mpin-backend>/servers
+ > cp demo/config_default.py demo/config.py
+```
+**2** Generate a base64-encoded 64-byte (or longer) random number. You can do this with the following command:
+```
+ > python -c "import os; print os.urandom(64).encode('base64')"
+ nju31zsOvxg+a0U4aCVrIOXf/VH/GC7/6oWK+8eEBM3OzNbbGOaL0mtne2g68O78MDYEz8fQz4MG
+ /7irix5Gfg==
+```
+**3** Using `vi` or other editor, edit the `demo/config.py` file<br/>
+**4** Change the `cookieSecret` parameter, setting its value to the random value that was just generated:
+```
+cookieSecret = "nju31zsOvxg+a0U4aCVrIOXf/VH/GC7/6oWK+8eEBM3OzNbbGOaL0mtne2g68O78MDYEz8fQz4MG/7irix5Gfg=="
+```
+**5** Change the `mpinJSURL` to the URL where the PIN Pad is served from. For instance:
+```
+mpinJSURL = "http://mpin.miracl.com/v4/mpin.js"
+```
+**6** Save the file and exit the editor
+
+### Running and Testing the Services
+
+For development purposes you might run the services from command line. Open 3 terminals and set the following two environment variables as shown below:
+```
+export PYTHONPATH=<mpin-backend>/lib:/usr/local/lib/python2.7/site-packages
+export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib
+```
+To run the services, perform the following commands, each in separate terminal:
+```
+ > python dta/dta.py
+```
+```
+ > python rps/rps.py
+```
+```
+ > python demo/mpinDemo.py
+```
+For more automated execution, you might need to write start/stop scripts in `/etc/init.d`
+
+Using the following commands, you can test whether the services are running fine
+- **D-TA**:
+```
+> curl http://127.0.0.1:8001/status
+{"service_name": "D-TA server", "message": "OK", "startTime": ["2015-03-13T15:09:41Z"]}
+```
+- **RPS**:
+```
+> curl http://127.0.0.1:8011/status
+{"message": "active", "version": "0.3"}
+> curl http://127.0.0.1:8011/rps/clientSettings
+{"requestOTP": false, "mpinAuthServerURL": "/rps", "timePermitsURL": "http://127.0.0.1:8011/rps/timePermit", "useWebSocket": false, "setDeviceName": false, "seedValue": "3cd2a085a056eb4748ed0ff9dd1b0e298a7bfbb59e96f75fb2228ab22f9b55133894d36edec166d5eb8c2941c0ddf16aea70f1fa392a41486c463c715e5b4a696c0bed2d264011852f23a33172a19636da27a5df90d49bcde5c8a36bb0c5cb1abe67345d", "accessNumberDigits": 7, "accessNumberURL": "http://127.0.0.1:8011/rps/accessnumber", "setupDoneURL": "http://127.0.0.1:8011/rps/setupDone", "timePermitsStorageURL": "https://timepermits.certivox.net", "authenticateURL": "/mpinAuthenticate", "certivoxURL": "https://community-api.certivox.net/v3/", "registerURL": "http://127.0.0.1:8011/rps/user", "appID": "a2fcdf24c98e11e4b69d02547e1fd4a1", "cSum": 1, "signatureURL": "http://127.0.0.1:8011/rps/signature", "getAccessNumberURL": "http://127.0.0.1:8011/rps/getAccessNumber", "mobileAuthenticateURL": "http://127.0.0.1:8011/rps/authenticate"}
+```
+- **Demo**:
+```
+> curl http://127.0.0.1:8005
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ . . .
+</head>
+<body>
+</body>
+ . . .
+</html>
+```
+Finally, open a browser on any machine that has network access to the machine on which the Milagro MFA Services are running. Browse to `http://<mpin-server-ip>:8005`.
+You should see the Demo application loaded.
+
+For further details on the Milagro MFA Server Configuration, see the [Documentation](http://docs.miracl.com/m-pin-core).
diff --git a/lib/crypto.py b/lib/crypto.py
new file mode 100644
index 0000000..b26ef6e
--- /dev/null
+++ b/lib/crypto.py
@@ -0,0 +1,373 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Wrapper function for mpin library."""
+import mpin
+import datetime
+from mpin_utils.common import Time
+
+# AES-GCM Key size
+PAS = mpin.PAS
+
+
+class CryptoError(Exception):
+
+ """Exception raises by crypto module."""
+
+ pass
+
+
+def today():
+ """Return time in slots since epoch using synced time"""
+ utc_dt = datetime.datetime.utcfromtimestamp(0)
+ return int((Time.syncedNow() - utc_dt).total_seconds() / 86400)
+
+
+def get_random_generator(seed):
+ """Return random number generator."""
+ SEED = mpin.ffi.new('octet*')
+ SEED_val = mpin.ffi.new('char [%s]' % len(seed), seed)
+ SEED[0].val = SEED_val
+ SEED[0].len = len(seed)
+ SEED[0].max = len(seed)
+
+ # random number generator
+ RNG = mpin.ffi.new('csprng*')
+ mpin.libmpin.CREATE_CSPRNG(RNG, SEED)
+
+ return RNG
+
+
+def random_generate(rng, length):
+ """Generate random number with predefined length."""
+ OTT = mpin.ffi.new('octet*')
+ OTTval = mpin.ffi.new('char []', length)
+ OTT[0].val = OTTval
+ OTT[0].max = length
+ OTT[0].len = length
+
+ mpin.libmpin.generateRandom(rng, OTT)
+ return mpin.toHex(OTT)
+
+
+def generate_otp(rng):
+ """Generate One time password."""
+ return mpin.libmpin.generateOTP(rng)
+
+
+def mpin_random_generate(rng):
+ """Generate random secret."""
+ MASTER_SECRET = mpin.ffi.new('octet*')
+ MASTER_SECRET_val = mpin.ffi.new('char []', mpin.PGS)
+ MASTER_SECRET[0].val = MASTER_SECRET_val
+ MASTER_SECRET[0].max = mpin.PGS
+ MASTER_SECRET[0].len = mpin.PGS
+
+ rtn = mpin.libmpin.MPIN_RANDOM_GENERATE(rng, MASTER_SECRET)
+ if rtn != 0:
+ raise CryptoError(rtn)
+
+ master_secret_hex = mpin.toHex(MASTER_SECRET)
+ return master_secret_hex.decode('hex')
+
+
+def aes_gcm_encrypt(master_secret, aes_key, rand, header, iv=mpin.IVL):
+ """Encrypt master secret."""
+ # AES Key
+ AES_KEY = mpin.ffi.new('octet*')
+ AES_KEY_val = mpin.ffi.new('char [%s]' % len(aes_key), aes_key)
+ AES_KEY[0].val = AES_KEY_val
+ AES_KEY[0].max = len(aes_key)
+ AES_KEY[0].len = len(aes_key)
+
+ # Initialisation Vector
+ IV = mpin.ffi.new('octet*')
+ IV_val = mpin.ffi.new('char []', iv)
+ IV[0].val = IV_val
+ IV[0].max = iv
+ IV[0].len = iv
+ mpin.libmpin.generateRandom(rand, IV)
+
+ # Authentication tag
+ TAG = mpin.ffi.new('octet*')
+ TAG_val = mpin.ffi.new('char []', mpin.PAS)
+ TAG[0].val = TAG_val
+ TAG[0].max = mpin.PAS
+
+ # Header
+ HEADER = mpin.ffi.new('octet*')
+ HEADER_val = mpin.ffi.new('char [%s]' % len(header), header)
+ HEADER[0].val = HEADER_val
+ HEADER[0].max = len(header)
+ HEADER[0].len = len(header)
+
+ # Plaintext input
+ plaintext = master_secret
+ PLAINTEXT = mpin.ffi.new('octet*')
+ PLAINTEXT_val = mpin.ffi.new('char [%s]' % len(plaintext), plaintext)
+ PLAINTEXT[0].val = PLAINTEXT_val
+ PLAINTEXT[0].max = len(plaintext)
+ PLAINTEXT[0].len = len(plaintext)
+
+ # Ciphertext
+ CIPHERTEXT = mpin.ffi.new('octet*')
+ CIPHERTEXT_val = mpin.ffi.new('char []', len(plaintext))
+ CIPHERTEXT[0].val = CIPHERTEXT_val
+ CIPHERTEXT[0].max = len(plaintext)
+
+ mpin.libmpin.AES_GCM_ENCRYPT(AES_KEY, IV, HEADER, PLAINTEXT, CIPHERTEXT, TAG)
+ IV_hex = mpin.toHex(IV)
+ CIPHERTEXT_hex = mpin.toHex(CIPHERTEXT)
+ TAG_hex = mpin.toHex(TAG)
+
+ return CIPHERTEXT_hex, IV_hex, TAG_hex
+
+
+def aes_gcm_decrypt(aes_key, iv, header, ciphertext):
+ """AES GCM Decrypt."""
+ # AES Key
+ AES_KEY = mpin.ffi.new('octet*')
+ AES_KEY_val = mpin.ffi.new('char [%s]' % len(aes_key), aes_key)
+ AES_KEY[0].val = AES_KEY_val
+ AES_KEY[0].max = len(aes_key)
+ AES_KEY[0].len = len(aes_key)
+
+ # Initialization Vector
+ IV = mpin.ffi.new('octet*')
+ IV_val = mpin.ffi.new('char [%s]' % len(iv), iv)
+ IV[0].val = IV_val
+ IV[0].max = len(iv)
+ IV[0].len = len(iv)
+
+ # Header
+ HEADER = mpin.ffi.new('octet*')
+ HEADER_val = mpin.ffi.new('char [%s]' % len(header), header)
+ HEADER[0].val = HEADER_val
+ HEADER[0].max = len(header)
+ HEADER[0].len = len(header)
+
+ # Ciphertext
+ CIPHERTEXT = mpin.ffi.new('octet*')
+ CIPHERTEXT_val = mpin.ffi.new('char [%s]' % len(ciphertext), ciphertext)
+ CIPHERTEXT[0].val = CIPHERTEXT_val
+ CIPHERTEXT[0].max = len(ciphertext)
+ CIPHERTEXT[0].len = len(ciphertext)
+
+ # Plaintext
+ PLAINTEXT = mpin.ffi.new('octet*')
+ PLAINTEXT_val = mpin.ffi.new('char []', CIPHERTEXT[0].len)
+ PLAINTEXT[0].val = PLAINTEXT_val
+ PLAINTEXT[0].max = CIPHERTEXT[0].len
+ PLAINTEXT[0].len = CIPHERTEXT[0].len
+
+ # Authentication tag
+ TAG = mpin.ffi.new('octet*')
+ TAG_val = mpin.ffi.new('char []', mpin.PAS)
+ TAG[0].val = TAG_val
+ TAG[0].max = mpin.PAS
+
+ # Decrypt ciphertext
+ mpin.libmpin.AES_GCM_DECRYPT(AES_KEY, IV, HEADER, CIPHERTEXT, PLAINTEXT, TAG)
+
+ return mpin.toHex(TAG), mpin.toHex(PLAINTEXT)
+
+
+def get_server_secret(master_secret):
+ """Generate secret secret."""
+ MASTER_SECRET = mpin.ffi.new('octet*')
+ MASTER_SECRET_val = mpin.ffi.new('char [%s]' % len(master_secret), master_secret)
+ MASTER_SECRET[0].val = MASTER_SECRET_val
+ MASTER_SECRET[0].max = len(master_secret)
+ MASTER_SECRET[0].len = len(master_secret)
+
+ SERVER_SECRET = mpin.ffi.new('octet*')
+ SERVER_SECRET_val = mpin.ffi.new('char []', mpin.G2)
+ SERVER_SECRET[0].val = SERVER_SECRET_val
+ SERVER_SECRET[0].max = mpin.G2
+ SERVER_SECRET[0].len = mpin.G2
+
+ rtn = mpin.libmpin.MPIN_GET_SERVER_SECRET(MASTER_SECRET, SERVER_SECRET)
+ if rtn != 0:
+ raise CryptoError(rtn)
+ return mpin.toHex(SERVER_SECRET)
+
+
+def get_client_multiple(master_secret, mpin_id):
+ """Generate client secret."""
+ MASTER_SECRET = mpin.ffi.new('octet*')
+ MASTER_SECRET_val = mpin.ffi.new('char [%s]' % len(master_secret), master_secret)
+ MASTER_SECRET[0].val = MASTER_SECRET_val
+ MASTER_SECRET[0].max = len(master_secret)
+ MASTER_SECRET[0].len = len(master_secret)
+
+ CLIENT_SECRET = mpin.ffi.new('octet*')
+ CLIENT_SECRET_val = mpin.ffi.new('char []', mpin.G1)
+ CLIENT_SECRET[0].val = CLIENT_SECRET_val
+ CLIENT_SECRET[0].max = mpin.G1
+ CLIENT_SECRET[0].len = mpin.G1
+
+ HASH_MPIN_ID = mpin.ffi.new('octet*')
+ HASH_MPIN_ID_val = mpin.ffi.new('char [%s]' % len(mpin_id), mpin_id)
+ HASH_MPIN_ID[0].val = HASH_MPIN_ID_val
+ HASH_MPIN_ID[0].max = len(mpin_id)
+ HASH_MPIN_ID[0].len = len(mpin_id)
+
+ rtn = mpin.libmpin.MPIN_GET_CLIENT_SECRET(MASTER_SECRET, HASH_MPIN_ID, CLIENT_SECRET)
+ if rtn != 0:
+ raise CryptoError(rtn)
+ return mpin.toHex(CLIENT_SECRET)
+
+
+def get_time_permit(master_secret, mpin_id, date=None):
+ """Generate client time permit."""
+ MASTER_SECRET = mpin.ffi.new('octet*')
+ MASTER_SECRET_val = mpin.ffi.new('char [%s]' % len(master_secret), master_secret)
+ MASTER_SECRET[0].val = MASTER_SECRET_val
+ MASTER_SECRET[0].max = len(master_secret)
+ MASTER_SECRET[0].len = len(master_secret)
+
+ TIME_PERMIT = mpin.ffi.new('octet*')
+ TIME_PERMIT_val = mpin.ffi.new('char []', mpin.G1)
+ TIME_PERMIT[0].val = TIME_PERMIT_val
+ TIME_PERMIT[0].max = mpin.G1
+ TIME_PERMIT[0].len = mpin.G1
+
+ HASH_MPIN_ID = mpin.ffi.new('octet*')
+ HASH_MPIN_ID_val = mpin.ffi.new('char [%s]' % len(mpin_id), mpin_id)
+ HASH_MPIN_ID[0].val = HASH_MPIN_ID_val
+ HASH_MPIN_ID[0].max = len(mpin_id)
+ HASH_MPIN_ID[0].len = len(mpin_id)
+
+ date = date or today()
+ rtn = mpin.libmpin.MPIN_GET_CLIENT_PERMIT(date, MASTER_SECRET, HASH_MPIN_ID, TIME_PERMIT)
+ if rtn != 0:
+ raise CryptoError(rtn)
+ return mpin.toHex(TIME_PERMIT)
+
+
+def mpin_recombine_g2(certivox_server_secret, customer_server_secret):
+ """Recombine server secret."""
+ SS1 = mpin.ffi.new("octet*")
+ SS1_val = mpin.ffi.new("char [%s]" % len(certivox_server_secret), certivox_server_secret)
+ SS1[0].val = SS1_val
+ SS1[0].max = mpin.G2
+ SS1[0].len = len(certivox_server_secret)
+
+ SS2 = mpin.ffi.new("octet*")
+ SS2_val = mpin.ffi.new("char [%s]" % len(customer_server_secret), customer_server_secret)
+ SS2[0].val = SS2_val
+ SS2[0].max = mpin.G2
+ SS2[0].len = len(customer_server_secret)
+
+ SERVER_SECRET = mpin.ffi.new("octet*")
+ SERVER_SECRET_val = mpin.ffi.new("char []", mpin.G2)
+ SERVER_SECRET[0].val = SERVER_SECRET_val
+ SERVER_SECRET[0].max = mpin.G2
+ SERVER_SECRET[0].len = mpin.G2
+
+ rtn = mpin.libmpin.MPIN_RECOMBINE_G2(SS1, SS2, SERVER_SECRET)
+ if rtn != 0:
+ raise CryptoError(rtn)
+ return mpin.toHex(SERVER_SECRET)
+
+
+def mpin_server_1(mpin_id, date):
+ """Calculate HID and HTOD."""
+ HID = mpin.ffi.new("octet*")
+ HIDval = mpin.ffi.new("char []", mpin.G1)
+ HID[0].val = HIDval
+ HID[0].max = mpin.G1
+ HID[0].len = mpin.G1
+
+ # H(T|H(ID))
+ HTID = mpin.ffi.new("octet*")
+ HTIDval = mpin.ffi.new("char []", mpin.G1)
+ HTID[0].val = HTIDval
+ HTID[0].max = mpin.G1
+ HTID[0].len = mpin.G1
+
+ MPIN_ID = mpin.ffi.new("octet*")
+ MPIN_ID_val = mpin.ffi.new("char [%s]" % len(mpin_id), mpin_id)
+ MPIN_ID[0].val = MPIN_ID_val
+ MPIN_ID[0].max = len(mpin_id)
+ MPIN_ID[0].len = len(mpin_id)
+
+ mpin.libmpin.MPIN_SERVER_1(date, MPIN_ID, HID, HTID)
+
+ return mpin.toHex(HID).decode('hex'), mpin.toHex(HTID).decode('hex')
+
+
+def mpin_server_2(server_secret, v, date, hid, htid, y, u, ut):
+ """Check credentials."""
+ SERVER_SECRET = mpin.ffi.new("octet*")
+ SERVER_SECRET_val = mpin.ffi.new("char [%s]" % len(server_secret), server_secret)
+ SERVER_SECRET[0].val = SERVER_SECRET_val
+ SERVER_SECRET[0].max = mpin.G2
+ SERVER_SECRET[0].len = len(server_secret)
+
+ V = mpin.ffi.new("octet*")
+ V_val = mpin.ffi.new("char [%s]" % len(v), v)
+ V[0].val = V_val
+ V[0].max = len(v)
+ V[0].len = len(v)
+
+ lenEF = 12 * mpin.PFS
+ E = mpin.ffi.new("octet*")
+ Eval = mpin.ffi.new("char []", lenEF)
+ E[0].val = Eval
+ E[0].max = lenEF
+ E[0].len = lenEF
+
+ F = mpin.ffi.new("octet*")
+ Fval = mpin.ffi.new("char []", lenEF)
+ F[0].val = Fval
+ F[0].max = lenEF
+ F[0].len = lenEF
+
+ HID = mpin.ffi.new("octet*")
+ HIDval = mpin.ffi.new("char [%s]" % len(hid), hid)
+ HID[0].val = HIDval
+ HID[0].max = len(hid)
+ HID[0].len = len(hid)
+
+ # H(T|H(ID))
+ HTID = mpin.ffi.new("octet*")
+ HTIDval = mpin.ffi.new("char [%s]" % len(htid), htid)
+ HTID[0].val = HTIDval
+ HTID[0].max = len(htid)
+ HTID[0].len = len(htid)
+
+ # Client part
+ Y = mpin.ffi.new("octet*")
+ Yval = mpin.ffi.new("char [%s]" % len(y), y)
+ Y[0].val = Yval
+ Y[0].max = len(y)
+ Y[0].len = len(y)
+
+ U = mpin.ffi.new("octet*")
+ Uval = mpin.ffi.new("char [%s]" % len(u), u)
+ U[0].val = Uval
+ U[0].max = len(u)
+ U[0].len = len(u)
+
+ UT = mpin.ffi.new("octet*")
+ UTval = mpin.ffi.new("char [%s]" % len(ut), ut)
+ UT[0].val = UTval
+ UT[0].max = len(ut)
+ UT[0].len = len(ut)
+
+ return mpin.libmpin.MPIN_SERVER_2(date, HID, HTID, Y, SERVER_SECRET, U, UT, V, E, F), mpin.toHex(E), mpin.toHex(F)
diff --git a/lib/dynamic_options.py b/lib/dynamic_options.py
new file mode 100644
index 0000000..fc1c142
--- /dev/null
+++ b/lib/dynamic_options.py
@@ -0,0 +1,121 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+
+from tornado.options import options
+from tornado.log import app_log as log
+from tornado.httpclient import AsyncHTTPClient
+from tornado.gen import coroutine
+
+
+def _extract_options(body, local_mapping):
+ try:
+ dynamic_options = json.loads(body)
+ log.debug(
+ "Recieved dynamic options: {0}"
+ .format(dynamic_options))
+ return dynamic_options
+ except Exception as e:
+ dynamic_options = {}
+ log.error(
+ "Can not parse dynamic options, exception: {0}"
+ .format(e))
+ return dynamic_options
+
+
+@coroutine
+def process_dynamic_options(name_mapping, handlers, application=None, initial=False):
+ """
+ Process dynamic updates.
+ `name_mappings` is a dict in the form `remote_option_name`: `local_option_name`
+ `handlers` is a list of callbacks to run after options update
+ `application` is the applicatiopn object, that may be needed by some handlers
+ `initial` is a boolean flag that tells if the caller is a request handler or main loop
+ Handlers signature:
+ `updated` is a list of actually updated options (ones that changed value)
+ `application` is the application object
+ `initial` is the initial flag
+ """
+ if not options.dynamicOptionsURL:
+ log.debug("Dynamic options disabled")
+ updated = {}
+ else:
+ try:
+ client = AsyncHTTPClient()
+ response = yield client.fetch(options.dynamicOptionsURL)
+ dynamic_options = _extract_options(response.body, name_mapping)
+ updated = update_dynamic_options(dynamic_options, name_mapping)
+ except Exception as e:
+ log.error("Error while getting dynamic options")
+ log.error(e)
+ updated = {}
+
+ for handler in handlers:
+ try:
+ handler(updated, application, initial)
+ except Exception as e:
+ log.error("Error while running update handlers")
+ log.error(e)
+
+
+def update_dynamic_options(dynamic_options, local_mapping):
+ updated = []
+ for remote_name, remote_value in dynamic_options.items():
+ if remote_name not in local_mapping:
+ log.debug(
+ "Recieved unsupported option for sync: {0}"
+ .format(remote_name))
+ continue
+ local_name = local_mapping[remote_name]
+ local_value = getattr(options, local_name)
+ if local_value != remote_value:
+ setattr(options, local_name, remote_value)
+ updated.append(local_name)
+ log.debug("Dynamic options for update {0}".format(updated))
+ return updated
+
+
+def generate_dynamic_options(local_mapping):
+ log.debug("Generating synamic options")
+ dynamic_options = {}
+ for remote_name, local_name in local_mapping.items():
+ dynamic_options[remote_name] = getattr(options, local_name)
+ return dynamic_options
+
+
+def _test_dynamic_options_update(
+ initial_options,
+ response_body,
+ expected_options,
+ local_mapping):
+ """
+ initial_options - dict of initial options to be set.
+ response_body - body of the recieved response (expected to be json).
+ expected_options - values to be checked in options object after processing.
+ local_mapping - local mapping for option names.
+ """
+
+ for option, value in initial_options.items():
+ setattr(options, option, value)
+
+ dynamic_options = json.loads(response_body)
+
+ update_dynamic_options(dynamic_options, local_mapping)
+
+ for option, value in expected_options.items():
+ assert getattr(options, option) == value
diff --git a/lib/entropy/__init__.py b/lib/entropy/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/lib/entropy/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/lib/entropy/base.py b/lib/entropy/base.py
new file mode 100644
index 0000000..dfff3a2
--- /dev/null
+++ b/lib/entropy/base.py
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+class EntropySourceBase(object):
+ entropyDescription = "Entropy source base"
+
+ def __init__(self, bytesNeeded, logger=None, **kwargs):
+ self.bytesNeeded = bytesNeeded
+ self.logger = logger
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+
+ def _getEntropy(self):
+ raise NotImplementedError
+
+ def getEntropy(self):
+ _bytes = b""
+ if self.logger:
+ self.logger.info("Getting entropy from {0}...".format(self.entropyDescription))
+
+ while len(_bytes) < self.bytesNeeded:
+ _bytes += self._getEntropy()
+
+ _result = _bytes[:self.bytesNeeded]
+
+ if self.logger:
+ self.logger.debug("Entropy from {0}: {1}".format(self.entropyDescription, _result.encode("hex")))
+
+ return _result
diff --git a/lib/entropy/certivox.py b/lib/entropy/certivox.py
new file mode 100644
index 0000000..0b82d50
--- /dev/null
+++ b/lib/entropy/certivox.py
@@ -0,0 +1,27 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from http import EntropySource as HttpEntropySource
+
+
+class EntropySource(HttpEntropySource):
+ entropyDescription = "CertiVox"
+ urlBase = "https://entropy.certivox.net/Generate.svc"
+
+ def __init__(self, bytesNeeded, **kwargs):
+ HttpEntropySource.__init__(self, bytesNeeded, **kwargs)
+ self.url = "{0}/{1}/hex".format(self.urlBase, bytesNeeded)
diff --git a/lib/entropy/dev_random.py b/lib/entropy/dev_random.py
new file mode 100644
index 0000000..a12536e
--- /dev/null
+++ b/lib/entropy/dev_random.py
@@ -0,0 +1,26 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from base import EntropySourceBase
+
+
+class EntropySource(EntropySourceBase):
+ entropyDescription = "/dev/random"
+ device = '/dev/random'
+
+ def _getEntropy(self):
+ return open(self.device, 'rb').read(self.bytesNeeded)
diff --git a/lib/entropy/dev_urandom.py b/lib/entropy/dev_urandom.py
new file mode 100644
index 0000000..14c5378
--- /dev/null
+++ b/lib/entropy/dev_urandom.py
@@ -0,0 +1,26 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from base import EntropySourceBase
+
+
+class EntropySource(EntropySourceBase):
+ entropyDescription = "/dev/urandom"
+ device = '/dev/urandom'
+
+ def _getEntropy(self):
+ return open(self.device, 'rb').read(self.bytesNeeded)
diff --git a/lib/entropy/http.py b/lib/entropy/http.py
new file mode 100644
index 0000000..3335be2
--- /dev/null
+++ b/lib/entropy/http.py
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from base import EntropySourceBase
+
+import urllib2
+
+
+class EntropySource(EntropySourceBase):
+ entropyDescription = "http"
+
+ def _getEntropy(self):
+ req = urllib2.urlopen(self.url)
+ buf = req.read()
+ return buf.decode("hex")
diff --git a/lib/mpDaemon.py b/lib/mpDaemon.py
new file mode 100755
index 0000000..2bfdab7
--- /dev/null
+++ b/lib/mpDaemon.py
@@ -0,0 +1,179 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import atexit
+import os
+import platform
+import sys
+import time
+from signal import SIGTERM
+
+
+def pidExists(pid):
+ if not pid:
+ return False
+
+ if (platform.system().lower() != "linux"):
+ return True
+
+ if not os.path.exists("/proc"):
+ return True
+
+ cmdline = ""
+ try:
+ cmdline = open("/proc/{0}/cmdline".format(pid), "r").read()
+ except Exception:
+ return False
+ if cmdline.find(os.path.split(__file__)[1]):
+ return True
+ else:
+ return False
+
+
+class Daemon(object):
+ """
+ A generic daemon class.
+
+ Usage: subclass the Daemon class and override the run() method
+ """
+ def __init__(self, **kwargs):
+ self.stdin = kwargs.get('stdin', '/dev/null')
+ self.stdout = kwargs.get('stdout', '/dev/null')
+ self.stderr = kwargs.get('stderr', '/dev/null')
+ self.pidfile = kwargs['pidfile']
+
+ def daemonize(self):
+ """
+ do the UNIX double-fork magic, see Stevens' "Advanced
+ Programming in the UNIX Environment" for details (ISBN 0201563177)
+ http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16
+ """
+ try:
+ pid = os.fork()
+ if pid > 0:
+ # exit first parent
+ sys.exit(0)
+ except OSError, e:
+ sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
+ sys.exit(1)
+
+ # decouple from parent environment
+ # os.chdir("/")
+ os.setsid()
+ os.umask(0)
+
+ # do second fork
+ try:
+ pid = os.fork()
+ if pid > 0:
+ # exit from second parent
+ sys.exit(0)
+ except OSError, e:
+ sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
+ sys.exit(1)
+
+ # redirect standard file descriptors
+ sys.stdout.flush()
+ sys.stderr.flush()
+ si = file(self.stdin, 'r')
+ so = file(self.stdout, 'a+')
+ se = file(self.stderr, 'a+', 0)
+
+ if (self.stdout != "/dev/null"):
+ os.chmod(self.stdout, 0600)
+ if (self.stderr != self.stdout) and (self.stderr != "/dev/null"):
+ os.chmod(self.stderr, 0600)
+
+ os.dup2(si.fileno(), sys.stdin.fileno())
+ os.dup2(so.fileno(), sys.stdout.fileno())
+ os.dup2(se.fileno(), sys.stderr.fileno())
+
+ # write pidfile
+ atexit.register(self.delpid)
+ pid = str(os.getpid())
+ file(self.pidfile, 'w+').write("%s\n" % pid)
+ os.chmod(self.pidfile, 0600)
+
+ def delpid(self):
+ sys.stdout.write("Stopping.\n")
+ sys.stdout.flush()
+ os.remove(self.pidfile)
+
+ def start(self):
+ """
+ Start the daemon
+ """
+ # Check for a pidfile to see if the daemon already runs
+ try:
+ pf = file(self.pidfile, 'r')
+ pid = int(pf.read().strip())
+ pf.close()
+ except IOError:
+ pid = None
+
+ if pidExists(pid):
+ message = "pidfile %s already exist. Daemon already running?\n"
+ sys.stderr.write(message % self.pidfile)
+ sys.exit(1)
+
+ # Start the daemon
+ self.daemonize()
+ self.run()
+
+ def stop(self):
+ """
+ Stop the daemon
+ """
+ # Get the pid from the pidfile
+ try:
+ pf = file(self.pidfile, 'r')
+ pid = int(pf.read().strip())
+ pf.close()
+ except IOError:
+ pid = None
+
+ if not pid:
+ message = "pidfile %s does not exist. Daemon not running?\n"
+ sys.stderr.write(message % self.pidfile)
+ return # not an error in a restart
+
+ # Try killing the daemon process
+ try:
+ while 1:
+ os.kill(pid, SIGTERM)
+ time.sleep(0.1)
+ except OSError, err:
+ err = str(err)
+ if err.find("No such process") > 0:
+ if os.path.exists(self.pidfile):
+ os.remove(self.pidfile)
+ else:
+ print str(err)
+ sys.exit(1)
+
+ def restart(self):
+ """
+ Restart the daemon
+ """
+ self.stop()
+ self.start()
+
+ def run(self):
+ """
+ You should override this method when you subclass Daemon. It will be called after the process has been
+ daemonized by start() or restart().
+ """
diff --git a/lib/mpWinService.py b/lib/mpWinService.py
new file mode 100755
index 0000000..90bad15
--- /dev/null
+++ b/lib/mpWinService.py
@@ -0,0 +1,106 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import time
+from os.path import splitext, abspath
+from sys import modules
+
+import servicemanager
+import win32api
+import win32event
+import win32service
+import win32serviceutil
+
+
+class Service(win32serviceutil.ServiceFramework):
+ _svc_name_ = '_unNamed'
+ _svc_display_name_ = '_Service Template'
+
+ def __init__(self, *args):
+ win32serviceutil.ServiceFramework.__init__(self, *args)
+ self.log('init')
+ self.stop_event = win32event.CreateEvent(None, 0, 0, None)
+
+ def log(self, msg):
+ servicemanager.LogInfoMsg(str(msg))
+
+ def sleep(self, sec):
+ win32api.Sleep(sec * 1000, True)
+
+ def SvcDoRun(self):
+ self.ReportServiceStatus(win32service.SERVICE_START_PENDING)
+ try:
+ self.ReportServiceStatus(win32service.SERVICE_RUNNING)
+ self.log('start')
+ self.start()
+ self.log('wait')
+ win32event.WaitForSingleObject(self.stop_event, win32event.INFINITE)
+ self.log('done')
+ except Exception, x:
+ self.log('Exception : %s' % x)
+ self.SvcStop()
+
+ def SvcStop(self):
+ self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
+ self.log('stopping')
+ self.stop()
+ self.log('stopped')
+ win32event.SetEvent(self.stop_event)
+ self.ReportServiceStatus(win32service.SERVICE_STOPPED)
+
+ def sleep(self, sec):
+ time.sleep(sec)
+
+ def start(self):
+ self.run()
+ self.runflag = True
+ while self.runflag:
+ self.sleep(1)
+
+ def stop(self):
+ self.runflag = False
+
+
+def installService(cls, name, display_name=None, stay_alive=True):
+ cls._svc_name_ = name
+ cls._svc_display_name_ = display_name or name
+ try:
+ module_path = modules[cls.__module__].__file__
+ except AttributeError:
+ # maybe py2exe went by
+ from sys import executable
+ module_path = executable
+ module_file = splitext(abspath(module_path))[0]
+ cls._svc_reg_class_ = '%s.%s' % (module_file, cls.__name__)
+ if stay_alive:
+ win32api.SetConsoleCtrlHandler(lambda x: True, True)
+ try:
+ win32serviceutil.InstallService(
+ cls._svc_reg_class_,
+ cls._svc_name_,
+ cls._svc_display_name_,
+ startType=win32service.SERVICE_AUTO_START
+ )
+ print 'Installing service %s... done' % name
+ win32serviceutil.StartService(
+ cls._svc_name_
+ )
+ print 'Starting service %s... done' % name
+ print '\nAll done!'
+
+ except Exception, x:
+ print str(x)
diff --git a/lib/mpin_utils/__init__.py b/lib/mpin_utils/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/lib/mpin_utils/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/lib/mpin_utils/common.py b/lib/mpin_utils/common.py
new file mode 100755
index 0000000..1acf06d
--- /dev/null
+++ b/lib/mpin_utils/common.py
@@ -0,0 +1,361 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import hashlib
+import hmac
+import json
+import os
+import time
+from pprint import pformat
+from urlparse import urlparse
+
+import tornado.httpclient
+from tornado.log import app_log as log
+
+proxies = {}
+
+# Time for which signatures are valid
+SIGNATURE_EXPIRES_OFFSET_SECONDS = 60
+
+
+class Keys(object):
+ app_id = "\0" * 16
+ app_key = ""
+ api_url = ""
+ timePermitsStorageURL = ""
+ managementConsoleURL = ""
+
+ @staticmethod
+ def loadFromFile(keysfile):
+ try:
+ with open(keysfile, "r") as keysfile:
+ data = json.loads(keysfile.read())
+ except IOError:
+ log.error("Cannot load keys file!")
+ return False
+ except ValueError:
+ log.error("Keys file contains invalid json!")
+ return False
+
+ try:
+ Keys.app_id = str(data["app_id"])
+ Keys.app_key = str(data["app_key"])
+ Keys.api_url = str(data["api_url"]).rstrip("/") + "/"
+ except KeyError:
+ log.error("Invalid keys file. app_id, app_key and api_url fields are required!")
+ return False
+
+ if 'certivox_server_secret' in data:
+ Keys.certivox_server_secret = data['certivox_server_secret']
+
+ return True
+
+ @staticmethod
+ def certivoxServer():
+ return Keys.api_url
+
+ @staticmethod
+ def timeServer():
+ return "{0}time".format(Keys.api_url)
+
+ @staticmethod
+ def _getAPISettings():
+
+ apiSettingsURL = "{0}apiSettings".format(Keys.api_url)
+ log.debug("Getting API settings from {0}".format(apiSettingsURL))
+
+ httpClient = tornado.httpclient.HTTPClient()
+ apiResponse = httpClient.fetch(apiSettingsURL, **fetchConfig(apiSettingsURL))
+ apiData = json.loads(apiResponse.body)
+ Keys.timePermitsStorageURL = apiData.get("timePermitsStorageURL", "")
+ Keys.managementConsoleURL = apiData.get("managementConsoleURL", "")
+ log.debug("timpePermitsStorageURL = {0}; managementConsoleURL = {1}".format(Keys.timePermitsStorageURL, Keys.managementConsoleURL))
+
+ @staticmethod
+ def getAPISettings(wait=False):
+ seconds = 5
+ while True:
+ try:
+ Keys._getAPISettings()
+ break
+ except Exception as E:
+ log.error(E)
+ log.error("Unable to get data from API server. Retrying in {0} seconds".format(seconds))
+ if not wait:
+ break
+ time.sleep(seconds)
+
+
+class Applications(object):
+ """
+ Load the file that contains the list of applications
+ Provide ability to add and remove apps
+ Provide ability to save applications to file
+ """
+
+ def __init__(self, filename=None):
+ self.filename = 'apps.json'
+ if not filename:
+ self.appData = {}
+ else:
+ data = json.load(open(filename, "r"))
+ self.appData = data['appData']
+
+ def addApp(self, app_id, app_key, app_url):
+ '''Add applications to memory and update file'''
+ self.appData[app_id] = [app_key, app_url]
+ self.writeFile()
+
+ def deleteApp(self, app_id):
+ '''Delete application from memory and update file'''
+ del self.appData[app_id]
+ self.writeFile()
+
+ def writeFile(self):
+ '''Write data in JSON format to file'''
+ data = {'appData': self.appData}
+ json.dump(data, open(self.filename, "w"))
+
+ def __repr__(self):
+ data = {}
+ data['appData'] = self.appData
+ return str(data)
+
+
+def detectProxy():
+ # Detect proxy settings for http and https from the environment
+ # Uses http_proxy and https_proxy environment variables
+
+ httpProxy = os.environ.get("HTTP_PROXY", os.environ.get("http_proxy", ""))
+ httpsProxy = os.environ.get("HTTPS_PROXY", os.environ.get("https_proxy", ""))
+ noProxy = os.environ.get("NO_PROXY", os.environ.get("no_proxy", ""))
+
+ if httpProxy:
+ u = urlparse(httpProxy)
+ proxies["http"] = {
+ "proxy_host": u.hostname or None,
+ "proxy_port": u.port or None,
+ "proxy_username": u.username or None,
+ "proxy_password": u.password or None
+ }
+
+ if httpsProxy:
+ u = urlparse(httpsProxy)
+ proxies["https"] = {
+ "proxy_host": u.hostname or None,
+ "proxy_port": u.port or None,
+ "proxy_username": u.username or None,
+ "proxy_password": u.password or None
+ }
+
+ if httpProxy or httpsProxy:
+ tornado.httpclient.AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")
+
+ if noProxy:
+ proxies["noProxy"] = noProxy.split(",")
+
+
+def fetchConfig(url):
+ # Get fetch config settings for httpclient for proxy
+ u = urlparse(url)
+ if u.scheme in proxies:
+ if u.hostname not in proxies.get("noProxy", []):
+ return proxies[u.scheme]
+
+ return {}
+
+
+class Time(object):
+ monthNames = ["Jan.", "Feb.", "Mar.", "Apr.", "May", "June", "July", "Aug.", "Sept.", "Oct.", "Nov.", "Dec."]
+
+ timeOffset = datetime.timedelta()
+
+ @staticmethod
+ def syncedNow(**kwards):
+ if kwards:
+ return datetime.datetime.utcnow() + Time.timeOffset + datetime.timedelta(**kwards)
+ else:
+ return datetime.datetime.utcnow() + Time.timeOffset
+
+ @staticmethod
+ def Now(**kwards):
+ if kwards:
+ return datetime.datetime.utcnow() + datetime.timedelta(**kwards)
+ else:
+ return datetime.datetime.utcnow()
+
+ @staticmethod
+ def DateTimeToISO(dt):
+ return dt.isoformat("T").split(".")[0] + "Z"
+
+ @staticmethod
+ def syncedISO(**kwards):
+ return Time.syncedNow(**kwards).isoformat("T").split(".")[0] + "Z"
+
+ @staticmethod
+ def ISO(**kwards):
+ return Time.Now(**kwards).isoformat("T").split(".")[0] + "Z"
+
+ @staticmethod
+ def ISOtoDateTime(isoDate):
+ isoDate = isoDate.split(".")[0].replace(" ", "T")
+ return datetime.datetime.strptime(isoDate.split("Z")[0], "%Y-%m-%dT%H:%M:%S")
+
+ @staticmethod
+ def DateTimetoHuman(d):
+ return d.strftime("%H:%M GMT, {0} %d, %Y".format(Time.monthNames[d.month - 1]))
+
+ @staticmethod
+ def _getTime(timeServer=None):
+ if not timeServer:
+ timeServer = Keys.timeServer()
+
+ log.info("Getting time from {0}".format(timeServer))
+ httpClient = tornado.httpclient.HTTPClient()
+ timeResponse = httpClient.fetch(timeServer, **fetchConfig(timeServer))
+ timeData = json.loads(timeResponse.body)
+ certivoxClock = timeData["Time"].replace(" ", "T")
+ certivoxTime = datetime.datetime.strptime(certivoxClock[:-1], '%Y-%m-%dT%H:%M:%S')
+ log.debug("CertiVox Time: %s" % certivoxTime)
+ log.debug("Local system time: %s" % datetime.datetime.utcnow())
+ Time.timeOffset = certivoxTime - datetime.datetime.utcnow()
+ log.info("Synced time: %s" % (datetime.datetime.utcnow() + Time.timeOffset))
+
+ @staticmethod
+ def getTime(wait=False, timeServer=None):
+ seconds = 5
+ while True:
+ try:
+ Time._getTime(timeServer)
+ break
+ except Exception as E:
+ log.error(E)
+ log.error("Unable to get data from the time server. Retrying in {0} seconds".format(seconds))
+ if not wait:
+ break
+ time.sleep(seconds)
+
+ @staticmethod
+ def DateTimetoEpoch(d):
+ return int(time.mktime(d.timetuple()) * 1000)
+
+
+class Seed(object):
+ seedValue = None
+
+ @staticmethod
+ def _getSeed(entropySources=""):
+ sources = entropySources.split(",")
+
+ totalSize = 100
+ totalEntropy = ""
+ for source in sources:
+ if not source.strip():
+ continue
+ s = source.split(":")
+ moduleName = s[0]
+ eSize = len(s) > 1 and int(s[1]) or totalSize
+
+ E = __import__("entropy.{0}".format(moduleName), globals(), locals(), ["EntropySource"]).EntropySource
+ totalEntropy += E(eSize, logger=log).getEntropy()
+ if len(totalEntropy) / 2 > totalSize:
+ break
+
+ if len(totalEntropy) < totalSize:
+ log.error("Seed value size is too small. Needed: {0} bytes, Got: {1} bytes.".format(totalSize, len(totalEntropy)))
+ raise Exception("Seed value size is too small. Check your configuration.")
+
+ Seed.seedValue = totalEntropy
+ log.debug("Seed.seedValue: %s" % Seed.seedValue.encode("hex"))
+ log.debug("Seed.seedValue length: %d" % len(Seed.seedValue))
+
+ @staticmethod
+ def getSeed(entropySources=""):
+ seconds = 5
+ while True:
+ try:
+ Seed._getSeed(entropySources)
+ break
+ except Exception as E:
+ log.error(E)
+ log.error("Unable to get seed. Retrying in {0} seconds".format(seconds))
+ time.sleep(seconds)
+
+
+def signMessage(message, key):
+ return hmac.new(key, message.encode('utf-8'), hashlib.sha256).hexdigest()
+
+
+def verifySignature(message, signature, key, expiresStr=None):
+ """
+ Verify the signature and also that the timestamp has not expired. If expiresStr is None, the timestamp is not checked.
+
+ Returns:
+ Valid - bool
+
+ """
+ nowTime = Time.syncedNow()
+ try:
+ expiresTime = expiresStr and Time.ISOtoDateTime(expiresStr) or nowTime
+ hmacExpected = hmac.new(key, message.encode('utf-8'), hashlib.sha256).hexdigest()
+ hmac1 = hmac.new(key, signature, hashlib.sha256).hexdigest()
+ hmac2 = hmac.new(key, hmacExpected, hashlib.sha256).hexdigest()
+ if hmac1 != hmac2:
+ reason = "Invalid signature"
+ valid = False
+ code = 401
+ elif nowTime > expiresTime:
+ reason = "Request expired"
+ valid = False
+ code = 408
+ else:
+ reason = "Valid signature"
+ valid = True
+ code = 200
+
+ except Exception as E:
+ valid = False
+ reason = "Error verifying message. {0}".format(E)
+ code = 500
+
+ if not valid:
+ debugData = {
+ "reason": reason,
+ "message": message,
+ "key": key,
+ "signature": signature,
+ "hmacExpected": hmacExpected,
+ "expiresStr": expiresStr,
+ "expiresTime": Time.DateTimeToISO(expiresTime),
+ "nowTime": Time.DateTimeToISO(nowTime)
+ }
+
+ log.debug("verifyMessage: {0}".format(pformat(debugData)))
+
+ return valid, reason, code
+
+
+def getLogLevel(logLevel):
+ return (type(logLevel) == int) and logLevel or {
+ 'CRITICAL': 50,
+ 'ERROR': 40,
+ 'WARN': 30,
+ 'INFO': 20,
+ 'DEBUG': 10,
+ 'ALL': 0,
+ }.get(logLevel.upper(), 20)
diff --git a/lib/mpin_utils/secrets.py b/lib/mpin_utils/secrets.py
new file mode 100644
index 0000000..66d9c92
--- /dev/null
+++ b/lib/mpin_utils/secrets.py
@@ -0,0 +1,400 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Utility functions for working with secrets."""
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import json
+import os
+import time
+
+from pbkdf2 import PBKDF2
+
+import tornado
+from tornado.httputil import url_concat
+from tornado.log import app_log as log
+from tornado.options import define, options
+
+import crypto
+from mpin_utils.common import (
+ fetchConfig,
+ Keys,
+ SIGNATURE_EXPIRES_OFFSET_SECONDS,
+ signMessage,
+ Time,
+)
+
+
+today = crypto.today
+
+
+def generate_aes_key(passphrase, salt):
+ """Return AES key (128 bit) using a pass-phrase.
+
+ It uses the passphrase argument or in its absence asks the user for
+ a pass-phrase to derive an AES key. The algorithm used is
+ Password-Based Key Derivation Function 2 (PBKDF2)
+
+ Keyword arguments:
+ passphrase -- A string used to generate an AES key
+ salt -- Salt value for the PBKDF2 Algorithm. 64 bits hex encoded
+ """
+ return PBKDF2(passphrase, str(salt)).read(16)
+
+
+def backup_master_secret(master_secret, encrypt_master_secret, passphrase, salt, backup_file, time, rng):
+ """Write the master secret to file."""
+ aes_key = generate_aes_key(passphrase, salt)
+ data = {
+ 'startTime': time.strftime('%Y-%m-%dT%H:%M:%SZ')
+ }
+
+ if encrypt_master_secret:
+ ciphertext_hex, iv_hex, tag_hex = crypto.aes_gcm_encrypt(
+ master_secret, aes_key, rng, time.strftime('%Y-%m-%dT%H:%M:%SZ'))
+ data.update({
+ 'IV': iv_hex,
+ 'ciphertext': ciphertext_hex,
+ 'tag': tag_hex})
+ else:
+ data['master_secret_hex'] = master_secret.encode('hex'),
+
+ with open(backup_file, 'w') as json_file:
+ json.dump(data, json_file)
+
+
+def generate_random_number(rng, length):
+ """Return random number with predefined length."""
+ return crypto.random_generate(rng, length)
+
+
+def generate_ott(length, rng, encoding=None):
+ """Generate a one time token (OTT).
+
+ Uses the Random Number Generator to generate a value
+ of length OTTLength set in the config file. This is
+ then encoded.
+ """
+ ott_hex = generate_random_number(rng, length)
+ if encoding == 'hex':
+ return ott_hex
+
+ ott = ott_hex.decode('hex')
+ if encoding:
+ ott = ott.encode(encoding)
+ return ott
+
+
+def generate_otp(rng):
+ """Generate a one time password (OTP).
+
+ Uses the Random Number Generator to generate 6 long value
+ """
+ return crypto.generate_otp(rng)
+
+
+def get_checksum(num, length):
+ """Return checksum."""
+ sum_digits = sum([
+ int(digit) * (length + 1 - i)
+ for i, digit in enumerate(str(num).zfill(length))])
+ checksum = (11 - (sum_digits % 11)) % 11
+ return checksum if checksum != 10 else None
+
+
+def generate_random_webid(rng, use_checksum=True):
+ """Generate a web identifier for mobile login.
+
+ Generates a random six digit integer. This is
+ appended with a one digit checksum.
+ """
+ num = generate_otp(rng)
+ checksum = get_checksum(num, 6) if use_checksum else ''
+
+ if not checksum and use_checksum:
+ return None
+
+ return "{0:06d}{1}".format(num, checksum)
+
+
+def generate_auth_ott(rng):
+ """Return auth OTT."""
+ return generate_random_number(rng, crypto.PAS)
+
+
+class SecretsError(Exception):
+
+ """Exception raises by secrets module."""
+
+ pass
+
+
+class MasterSecret(object):
+
+ """Master Secret."""
+
+ master_secret = None
+ start_time = None
+
+ def __init__(self, passphrase, salt, seed, time, backup_file=None, encrypt_master_secret=True):
+ """Constructor."""
+ self.rng = crypto.get_random_generator(seed)
+ self.master_secret, self.start_time = self._get_master_secret(
+ passphrase, salt, time, backup_file, encrypt_master_secret)
+
+ def _get_master_secret(self, passphrase, salt, time, backup_file=None, encrypt_master_secret=True):
+ """Restore/generate master secret.
+
+ Restore from backup_file if such is provided, generate new otherwise.
+ Set backup_file=None for in memory master_secret.
+ """
+ if not backup_file:
+ log.info('Master Secret Share not backed up to file')
+ return self._generate_master_secret(), time
+
+ if not os.path.exists(backup_file):
+ log.info('Master Secret backup file doesn\'t exists. Generate new.')
+ master_secret = self._generate_master_secret()
+ backup_master_secret(
+ master_secret, encrypt_master_secret, passphrase, salt, backup_file, time, self.rng)
+ return master_secret, time
+
+ log.info('Restore Master Secret Share from file')
+ return self._restore_master_secret(
+ backup_file,
+ encrypt_master_secret,
+ passphrase,
+ salt)
+
+ def _generate_master_secret(self):
+ """Generate the M-Pin Master Secret."""
+ try:
+ return crypto.mpin_random_generate(self.rng)
+ except crypto.CryptoError as e:
+ log.error(e)
+ raise SecretsError('M-Pin Master Secret Generation Failed')
+
+ def _restore_master_secret(self, backup_file, encrypt_master_secret, passphrase, salt):
+ """Restore secret from file.
+
+ Decode secret if encrypted.
+ """
+ try:
+ with open(backup_file) as json_file:
+ backup = json.load(json_file)
+ except ValueError:
+ raise SecretsError('Master Secret backup file is corrupted.')
+
+ if encrypt_master_secret:
+ tag, plaintext = crypto.aes_gcm_decrypt(
+ aes_key=generate_aes_key(passphrase, salt),
+ iv=str(backup['IV'].decode('hex')),
+ header=str(backup['startTime']),
+ ciphertext=str(backup['ciphertext'].decode('hex')))
+
+ # Check authentication tag
+ if backup['tag'] != tag:
+ raise SecretsError('AES-GSM Decryption Failed. Authentication tag is not correct')
+
+ self.start_time = Time.ISOtoDateTime(str(backup['startTime']))
+ master_secret = plaintext.decode('hex')
+ else:
+ self.start_time = Time.ISOtoDateTime(backup['startTime'])
+ master_secret = backup['master_secret_hex'].decode('hex')
+
+ return master_secret, self.start_time
+
+ def get_server_secret(self):
+ """Generate server secret."""
+ try:
+ return crypto.get_server_secret(self.master_secret)
+ except crypto.CryptoError as e:
+ log.error(e)
+ raise SecretsError('Server Secret generation failed')
+
+ def get_client_secret(self, mpin_id):
+ """Generate client secret."""
+ try:
+ return crypto.get_client_multiple(self.master_secret, mpin_id)
+ except crypto.CryptoError as e:
+ log.error(e)
+ raise SecretsError('Client secret generation failed')
+
+ def get_time_permits(self, mpin_id, count):
+ """Generate client time permit."""
+ start_date = crypto.today()
+ try:
+ return dict(
+ (date, crypto.get_time_permit(self.master_secret, mpin_id, date))
+ for date in range(start_date, start_date + count))
+ except crypto.CryptoError as e:
+ log.error(e)
+ raise SecretsError('M-Pin Time Permit Generation Failed')
+
+
+define("certivoxServerSecret", default='dta', type=unicode)
+
+
+class ServerSecret(object):
+
+ """Server Secret."""
+
+ server_secret = None
+
+ def __init__(self, seed, app_id, app_key):
+ """Constructor."""
+ self.rng = crypto.get_random_generator(seed)
+ self.app_id = app_id
+ self.app_key = app_key
+ self.server_secret = self._get_server_secret()
+
+ def _get_certivox_server_secret_share_dta(self, expires):
+ path = 'serverSecret'
+ url_params = url_concat('{0}{1}'.format(Keys.certivoxServer(), path), {
+ 'app_id': self.app_id,
+ 'expires': expires,
+ 'signature': signMessage('{0}{1}{2}'.format(path, self.app_id, expires), self.app_key)
+ })
+ log.debug('MIRACL server secret request: {0}'.format(url_params))
+ httpclient = tornado.httpclient.HTTPClient()
+ try:
+ response = httpclient.fetch(url_params, **fetchConfig(url_params))
+ except tornado.httpclient.HTTPError as e:
+ log.error(e)
+ raise SecretsError('Unable to get Server Secret from the MIRACL TA server')
+ httpclient.close()
+
+ try:
+ data = json.loads(response.body)
+ except ValueError as e:
+ log.error(e)
+ raise SecretsError('Invalid response from TA server')
+
+ if 'serverSecret' not in data:
+ raise SecretsError('serverSecret not in response from TA server')
+
+ return data["serverSecret"]
+
+ def _get_certivox_server_secret_share_credentials(self, expires):
+ if not hasattr(Keys, 'certivox_server_secret'):
+ raise SecretsError(
+ 'MIRACL server secret share is not in the credentials.json. '
+ 'You can get it by: \n'
+ 'scripts/getServerSecretShare.py credentials.json > credentials_with_secret.json')
+ return Keys.certivox_server_secret
+
+ def _get_certivox_server_secret_share(self, expires):
+ method = options.certivoxServerSecret
+ methods = {
+ 'dta': self._get_certivox_server_secret_share_dta,
+ 'credentials.json': self._get_certivox_server_secret_share_credentials,
+ 'manual': lambda x: raw_input('MIRACL server secret share:'),
+ 'config': lambda x: options.certivoxServerSecret
+ }
+ func = methods[method if method in methods else 'config']
+ certivox_server_secret_hex = func(expires)
+
+ try:
+ return certivox_server_secret_hex.decode("hex")
+ except TypeError as e:
+ log.error(e)
+ raise SecretsError('Invalid CertiVox server secret share')
+
+ def _get_customer_server_secret_share(self, expires):
+ path = 'serverSecret'
+ url_params = url_concat(
+ '{0}/{1}'.format(options.DTALocalURL, path),
+ {
+ 'app_id': self.app_id,
+ 'expires': expires,
+ 'signature': signMessage('{0}{1}{2}'.format(path, self.app_id, expires), self.app_key)
+ })
+ log.debug('customer server secret request: {0}'.format(url_params))
+
+ httpclient = tornado.httpclient.HTTPClient()
+
+ import socket
+ # Make at most 30 attempts to get server secret from local TA
+ for attempt in range(30):
+ try:
+ response = httpclient.fetch(url_params)
+ except (tornado.httpclient.HTTPError, socket.error) as e:
+ log.error(e)
+ log.error(
+ 'Unable to get Server Secret from the customer TA server. '
+ 'Retying...')
+ time.sleep(2)
+ continue
+
+ httpclient.close()
+ break
+ else:
+ # Max attempts reached
+ raise SecretsError(
+ 'Unable to get Server Secret from the customer TA server.')
+
+ try:
+ data = json.loads(response.body)
+ except ValueError:
+ raise SecretsError('TA server response contains invalid JSON')
+
+ if 'serverSecret' not in data:
+ raise SecretsError('serverSecret not in response from TA server')
+
+ return data["serverSecret"].decode("hex")
+
+ def _get_server_secret(self):
+ expires = Time.syncedISO(seconds=SIGNATURE_EXPIRES_OFFSET_SECONDS)
+ certivox_server_secret = self._get_certivox_server_secret_share(expires)
+ customer_server_secret = self._get_customer_server_secret_share(expires)
+
+ try:
+ server_secret_hex = crypto.mpin_recombine_g2(certivox_server_secret, customer_server_secret)
+ except crypto.CryptoError as e:
+ log.error(e)
+ raise SecretsError('M-Pin Server Secret Generation Failed')
+
+ return server_secret_hex.decode("hex")
+
+ def get_pass1_value(self):
+ """Return pass1 value."""
+ try:
+ random_number = crypto.mpin_random_generate(self.rng)
+ except crypto.CryptoError as e:
+ log.error(e)
+ raise SecretsError('Pass 1 - failed to generate Y')
+
+ return random_number.encode('hex')
+
+ def validate_pass2_value(self, mpin_id, u, ut, y, v):
+ """Validate pass2 value.
+
+ y - pass 1 values
+ v - pass 2 value in question
+ """
+ date = crypto.today()
+ check_dates = [date]
+ if Time.syncedNow().hour < 1:
+ check_dates.append(date - 1)
+
+ for date in check_dates:
+ hid, htid = crypto.mpin_server_1(mpin_id, date)
+ success, _, _ = crypto.mpin_server_2(self.server_secret, v, date, hid, htid, y, u, ut)
+ if success != -19:
+ break
+
+ return success
diff --git a/lib/storage/__init__.py b/lib/storage/__init__.py
new file mode 100644
index 0000000..60493f0
--- /dev/null
+++ b/lib/storage/__init__.py
@@ -0,0 +1,56 @@
+#! /usr/bin/python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import sys
+
+from tornado.options import define, options
+from tornado.log import app_log as log
+
+
+define("storage", default="memory")
+
+define("redisHost", default="127.0.0.1")
+define("redisPort", default=6379)
+define("redisDB", default=0)
+define("redisPassword", default=None)
+define("redisPrefix", default="mpin")
+
+define("fileStorageLocation", type=unicode)
+
+
+class StorageError(Exception):
+ pass
+
+
+def get_storage_cls():
+ if options.storage == "redis":
+ from storage.backends.redis import MPinStorage
+ elif options.storage == "memory":
+ from storage.backends.memory import MPinStorage
+ elif options.storage == "json":
+ if not options.fileStorageLocation:
+ raise StorageError('File storage requires fileStorageLocation option')
+ from storage.backends.file import MPinStorage
+ else:
+ log.error("Invalid storage: {0}".format(options.storage))
+ sys.exit(1)
+
+ return MPinStorage
diff --git a/lib/storage/backends/__init__.py b/lib/storage/backends/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/lib/storage/backends/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/lib/storage/backends/base.py b/lib/storage/backends/base.py
new file mode 100644
index 0000000..1c8208b
--- /dev/null
+++ b/lib/storage/backends/base.py
@@ -0,0 +1,139 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from storage.item import Item
+
+
+class BaseIndex(dict):
+
+ def __init__(self, storage, name, fields):
+ super(BaseIndex, self).__init__()
+
+ self.storage = storage
+ self.name = name
+ self.fields = fields[:]
+
+ def _add_item(self, key, obj):
+ raise NotImplemented
+
+ def _find_item(self, key):
+ raise NotImplemented
+
+ def _delete_item(self, key):
+ raise NotImplemented
+
+ def _get_item_key(self, obj):
+ key = []
+ for field_name in self.fields:
+ if type(obj) is dict:
+ val = obj.get(field_name)
+ else:
+ val = getattr(obj, field_name, None)
+ # update index only if all the fields have values
+ if not val:
+ return None
+ key.append(str(val))
+ return "_".join(key)
+
+ def add(self, obj):
+ key = self._get_item_key(obj)
+ if key:
+ self._add_item(key, obj)
+
+ def find(self, **kwargs):
+ key = self._get_item_key(kwargs)
+ if key:
+ return self._find_item(key)
+ else:
+ return None
+
+ def delete(self, obj):
+ key = self._get_item_key(obj)
+ if key:
+ self._delete_item(key)
+
+
+class BaseStorage(object):
+ index_cls = BaseIndex
+
+ def __init__(self, ioloop, *indexes):
+ '''
+ Initialize the in-memory storage and create indexes defined as a list of parameters.
+ The indexed fields must be coma-separated.
+
+ Example:
+ M = MPinStorage("mpinid", "mpinid,wid") #Will create two indexes: by mpinid and by mpinid and wid
+ '''
+
+ self.ioloop = ioloop
+ self.indexes = {}
+
+ # Create indexes
+ for index in indexes:
+ index_fields = map(lambda x: x.strip(), index.split(","))
+ index_name = ",".join(index_fields)
+
+ self.indexes[index_name] = self.index_cls(
+ storage=self,
+ name=index_name,
+ fields=index_fields,
+ )
+
+ def _add_item(self, item):
+ raise NotImplemented
+
+ def _find_item(self, index, **kwargs):
+ raise NotImplemented
+
+ def _delete_item(self, item):
+ raise NotImplemented
+
+ def _delete_from_indexes(self, item):
+ for index in self.indexes.itervalues():
+ index.delete(item)
+
+ def _storage_change(self):
+ pass
+
+ def update_item(self, item):
+ self.update_index(item)
+ self._add_item(item)
+
+ def add(self, expire_time=None, **kwargs):
+ item = Item(self, expire_time, **kwargs)
+ self._add_item(item)
+ return item
+
+ def find(self, **kwargs):
+ correct_index = None
+ for index in self.indexes.itervalues():
+ if sorted(kwargs.keys()) == sorted(index.fields):
+ correct_index = index
+ break
+
+ if correct_index is not None:
+ return self._find_item(correct_index, **kwargs)
+ else:
+ return None
+
+ def delete(self, item):
+ self._delete_item(item)
+ self._delete_from_indexes(item)
+
+ def update_index(self, item):
+ for index in self.indexes.itervalues():
+ index.add(item)
diff --git a/lib/storage/backends/file.py b/lib/storage/backends/file.py
new file mode 100644
index 0000000..1615621
--- /dev/null
+++ b/lib/storage/backends/file.py
@@ -0,0 +1,100 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import dateutil.parser
+import json
+import os.path
+
+from tornado.options import options
+
+from storage.backends.memory import (
+ Index as MemoryIndex,
+ MPinStorage as MemoryStorage,
+)
+from storage.item import Item
+
+
+class MyEncoder(json.JSONEncoder):
+
+ def default(self, obj):
+ if isinstance(obj, datetime.datetime):
+ return obj.isoformat()
+
+ elif isinstance(obj, Item):
+ return obj.dict
+
+ return json.JSONEncoder.default(self, obj)
+
+
+class Index(MemoryIndex):
+ pass
+
+
+class MPinStorage(MemoryStorage):
+ def __init__(self, *args, **kwargs):
+ super(MPinStorage, self).__init__(*args, **kwargs)
+ self._deserialize()
+
+ def _storage_change(self):
+ self._serialize()
+
+ def _serialize(self):
+ data = {
+ 'expires_list': self._expires_list,
+ 'items': self._items,
+ 'indexes': self.indexes,
+ }
+ json_data = json.dumps(data, cls=MyEncoder)
+ with open(options.fileStorageLocation, 'w') as json_storage:
+ json_storage.write(json_data)
+
+ def _deserialize_expires_list(self, data):
+ return [
+ (dateutil.parser.parse(expiration), _id)
+ for expiration, _id in data.get('expires_list', [])
+ ]
+
+ def _deserialize_items(self, data):
+ return dict((
+ (_id, Item(self, None, **item_data))
+ for _id, item_data in data.get('items', {}).iteritems()
+ ))
+
+ def _deserialize_index(self, data):
+ indexes = {}
+ for index_name, index_data in data.get('indexes', {}).iteritems():
+ index = Index(self, index_name, index_name.split(','))
+ for key, value in index_data.iteritems():
+ index[key] = value
+
+ indexes[index_name] = index
+
+ return indexes
+
+ def _deserialize(self):
+ if not os.path.isfile(options.fileStorageLocation):
+ return
+
+ with open(options.fileStorageLocation, 'r') as json_storage:
+ data = json.load(json_storage)
+
+ self._expires_list = self._deserialize_expires_list(data)
+ self._items = self._deserialize_items(data)
+ self.indexes = self._deserialize_index(data)
+
+ self._schedule_expiration_check()
diff --git a/lib/storage/backends/memory.py b/lib/storage/backends/memory.py
new file mode 100644
index 0000000..5cec7a6
--- /dev/null
+++ b/lib/storage/backends/memory.py
@@ -0,0 +1,101 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import bisect
+import datetime
+
+from mpin_utils.common import Time
+
+from storage.backends.base import BaseIndex, BaseStorage
+
+
+class Index(BaseIndex):
+
+ def _add_item(self, key, obj):
+ self[key] = obj._id
+
+ def _find_item(self, key):
+ return self.get(key)
+
+ def _delete_item(self, key):
+ if key in self:
+ del self[key]
+
+
+class MPinStorage(BaseStorage):
+ index_cls = Index
+
+ def __init__(self, *args, **kwargs):
+ super(MPinStorage, self).__init__(*args, **kwargs)
+ self._expires_list = []
+ self._timeout = None
+ self._items = {}
+
+ def __len__(self):
+ return len(self._items)
+
+ def _add_item(self, item):
+ item._active = True
+ self._items[item._id] = item
+ if item._expiration_datetime:
+ bisect.insort_left(self._expires_list, (item._expiration_datetime, item._id))
+ self._schedule_expiration_check()
+
+ self._storage_change()
+
+ def _find_item(self, index, **kwargs):
+ _id = index.find(**kwargs)
+ item = self._items.get(_id)
+ if item and item._active:
+ return item
+ else:
+ return None
+
+ def _delete_item(self, item):
+ item._active = False
+
+ def _schedule_expiration_check(self):
+ if self._timeout:
+ self.ioloop.remove_timeout(self._timeout)
+ self._timeout = None
+
+ while len(self._expires_list) > 0:
+ item_expiration, item_id = self._expires_list[0]
+
+ try:
+ item = self._items[item_id]
+ except KeyError:
+ del self._expires_list[0]
+ continue
+
+ now = Time.syncedNow()
+ if not item._active or item_expiration < now:
+ del self._expires_list[0]
+ del self._items[item_id]
+ self._delete_from_indexes(item)
+ continue
+
+ # No more expired items, schedule next check
+ self._timeout = self.ioloop.add_timeout(
+ item_expiration - now + datetime.timedelta(milliseconds=100),
+ self._schedule_expiration_check
+ )
+ break
+
+ self._storage_change()
diff --git a/lib/storage/backends/redis.py b/lib/storage/backends/redis.py
new file mode 100644
index 0000000..ccd80e3
--- /dev/null
+++ b/lib/storage/backends/redis.py
@@ -0,0 +1,105 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import json
+
+import redis
+
+from tornado.options import options
+
+from mpin_utils.common import Time
+
+from storage.item import Item
+from storage.backends.base import BaseIndex, BaseStorage
+
+
+def get_redis_id(key):
+ return '_'.join([options.redisPrefix, key])
+
+
+class RedisConnection(object):
+
+ def __init__(self, host, port, password, db):
+ self.redis = redis.StrictRedis(host, port, password, db)
+
+ def _execute(self, command, *args, **kwargs):
+ method = getattr(self.redis, command)
+ return method(*args, **kwargs)
+
+ def add(self, key, expires, value):
+ if expires:
+ self._execute("setex", key, (expires - Time.syncedNow()), value)
+ else:
+ self._execute("set", key, value)
+
+ def get(self, key):
+ return self._execute("get", key)
+
+ def delete(self, key):
+ return self._execute("delete", key)
+
+
+class Index(BaseIndex):
+
+ def _get_item_key(self, obj):
+ key = super(Index, self)._get_item_key(obj)
+ if key:
+ return get_redis_id(key)
+ else:
+ return None
+
+ def _add_item(self, key, obj):
+ self.storage.redis.add(key, obj._expiration_datetime, obj._id)
+
+ def _find_item(self, key):
+ return self.storage.redis.get(key)
+
+ def _delete_item(self, key):
+ self.storage.redis.delete(key)
+
+
+class MPinStorage(BaseStorage):
+ index_cls = Index
+
+ def __init__(self, *args, **kwargs):
+ super(MPinStorage, self).__init__(*args, **kwargs)
+ self.redis = RedisConnection(
+ host=options.redisHost,
+ port=options.redisPort,
+ password=options.redisPassword,
+ db=options.redisDB
+ )
+
+ def _add_item(self, item):
+ self.redis.add(
+ get_redis_id(item._id),
+ item._expiration_datetime,
+ item.json
+ )
+
+ def _find_item(self, index, **kwargs):
+ _id = index.find(**kwargs)
+ if not _id:
+ return None
+
+ data = self.redis.get(get_redis_id(_id))
+ return Item(self, None, **json.loads(data))
+
+ def _delete_item(self, item):
+ self.redis.delete(get_redis_id(item._id))
diff --git a/lib/storage/item.py b/lib/storage/item.py
new file mode 100644
index 0000000..6a38327
--- /dev/null
+++ b/lib/storage/item.py
@@ -0,0 +1,79 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import datetime
+import json
+import uuid
+
+from mpin_utils.common import Time
+
+
+class Item(object):
+
+ def __init__(self, storage, expire_time, **kwargs):
+ '''expireTime should be in ISO format'''
+ self.__fields = ["_id", "_active", "_expires"]
+ self.__storage = storage
+
+ self._id = uuid.uuid1().hex
+
+ if isinstance(expire_time, datetime.datetime):
+ self._expires = expire_time.isoformat()
+ else:
+ self._expires = expire_time
+
+ self._update_item(**kwargs)
+
+ self._expiration_datetime = None
+ if self._expires:
+ self._expiration_datetime = Time.ISOtoDateTime(self._expires)
+
+ self.__storage.update_index(self)
+
+ def __getattr__(self, name):
+ return None
+
+ def __str__(self):
+ return "\n".join([
+ "{0}: {1}".format(k, getattr(self, k)) for k in self.__fields
+ ])
+
+ def _update_item(self, **kwargs):
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+ if key in self.__fields:
+ if not value:
+ self.__fields.remove(key)
+ else:
+ self.__fields.append(key)
+
+ @property
+ def dict(self):
+ return dict([(k, getattr(self, k)) for k in self.__fields])
+
+ @property
+ def json(self):
+ return json.dumps(self.dict)
+
+ def update(self, **kwargs):
+ self._update_item(**kwargs)
+ self.__storage.update_item(self)
+
+ def delete(self):
+ self.__storage.delete(self)
diff --git a/lib/storage/tests/__init__.py b/lib/storage/tests/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/lib/storage/tests/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/lib/storage/tests/cases.py b/lib/storage/tests/cases.py
new file mode 100644
index 0000000..633898d
--- /dev/null
+++ b/lib/storage/tests/cases.py
@@ -0,0 +1,318 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Common test cases for storage."""
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+from datetime import datetime, timedelta
+
+import pytest
+
+
+def simplekey_index_case(storage):
+ """
+ indexes = 'id1'
+ """
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=2.1,
+ id2=2.2,
+ key='value2'
+ )
+
+ item1 = storage.find(id1=1.1)
+ item2 = storage.find(id1=2.1)
+
+ assert item1.key == 'value1'
+ assert item2.key == 'value2'
+ assert not storage.find(id2=1.2) # Log warning at this point
+
+ storage.delete(item1)
+ assert not storage.find(id1=1.1)
+ assert storage.find(id1=2.1)
+
+
+def multi_indexes_case(storage):
+ """
+ indexes = 'id1', 'id2'
+ """
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=2.1,
+ id2=2.2,
+ key='value2'
+ )
+
+ assert storage.find(id1=1.1).key == 'value1'
+ assert storage.find(id2=2.2).key == 'value2'
+
+ assert not storage.find(id1=2.1, id2=2.2)
+ assert not storage.find(id1=1.1, id2=2.2)
+ assert not storage.find(id1=1.1, id2=2.2)
+ assert not storage.find(id1=2.1, key='value2')
+
+
+def multikey_indexes(storage):
+ """
+ indexes = 'id1,id2'
+ """
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=2.1,
+ id2=2.2,
+ key='value2'
+ )
+
+ assert storage.find(id1=2.1, id2=2.2).key == 'value2'
+ assert not storage.find(id1=1.1)
+ assert not storage.find(id1=1.1, id2=2.2)
+ assert not storage.find(id1=2.1, key='value2')
+
+
+def mixkey_indexes_case(storage):
+ """
+ indexes = 'id1', 'id1,id2'
+ """
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=2.1,
+ id2=2.2,
+ key='value2'
+ )
+
+ assert storage.find(id1=2.1, id2=2.2).key == 'value2'
+ assert storage.find(id1=1.1).key == 'value1'
+ assert not storage.find(id2=2.2)
+ assert not storage.find(id1=1.1, id2=2.2)
+ assert not storage.find(id1=2.1, key='value2')
+
+
+def missing_key_indexes_case(storage):
+ """
+ indexes = 'id1', 'id2'
+ """
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=2.1,
+ key='value2'
+ )
+
+ assert storage.find(id1=1.1).key == 'value1'
+ assert storage.find(id2=1.2).key == 'value1'
+ assert storage.find(id1=2.1).key == 'value2'
+
+ assert not storage.find(id2=2.2)
+
+ item2 = storage.find(id1=2.1)
+ storage.delete(item2)
+ assert not storage.find(id1=2.1)
+
+
+def valid_string_date_case(storage):
+ """
+ indexes = 'id1', 'id2'
+ """
+
+ storage.add(
+ (datetime.now() + timedelta(days=1)).isoformat(),
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ (datetime.now() + timedelta(days=1)).isoformat(),
+ id1=2.1,
+ id2=2.2,
+ key='value2'
+ )
+
+ assert storage.find(id1=2.1).key == 'value2'
+ assert storage.find(id2=1.2).key == 'value1'
+
+
+def invalid_string_date_case(storage):
+ """
+ indexes = 'id1', 'id2'
+ """
+
+ with pytest.raises(ValueError):
+ storage.add(
+ 'invalid date',
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ assert not storage.find(id1=1.1)
+
+
+def no_date_case(storage):
+ """
+ indexes = 'id1', 'id2'
+ """
+
+ storage.add(
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ id1=2.1,
+ id2=2.2,
+ key='value2'
+ )
+
+ assert storage.find(id1=2.1).key == 'value2'
+ assert storage.find(id2=1.2).key == 'value1'
+
+
+def item_deletion_case(storage):
+ """
+ indexes = 'id1', 'id2'
+ """
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=1.1,
+ id2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=2.1,
+ key='value2'
+ )
+
+ assert storage.find(id1=1.1).key == 'value1'
+ assert storage.find(id2=1.2).key == 'value1'
+ assert storage.find(id1=2.1).key == 'value2'
+ assert not storage.find(id2=2.2)
+
+ item1 = storage.find(id1=1.1)
+ storage.delete(item1)
+
+ # try to delete item that does not exist in index
+ storage.delete(item1)
+
+ assert not storage.find(id1=1.1)
+ assert not storage.find(id2=1.2)
+ assert storage.find(id1=2.1).key == 'value2'
+ assert not storage.find(id2=2.2)
+
+
+def keys_with_underscore_case(storage):
+ """
+ indexes = 'id_1', 'id_1,id_2'
+ """
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id_1=1.1,
+ id_2=1.2,
+ key='value1'
+ )
+
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id_1=2.1,
+ id_2=2.2,
+ key='value2'
+ )
+
+ assert storage.find(id_1=2.1, id_2=2.2).key == 'value2'
+ assert storage.find(id_1=1.1).key == 'value1'
+ assert not storage.find(id_2=2.2)
+ assert not storage.find(id_1=1.1, id_2=2.2)
+ assert not storage.find(id_1=2.1, key='value2')
+
+
+def update_item_case(storage):
+ """
+ indexes = 'id1'
+ """
+ storage.add(
+ datetime.now() + timedelta(days=1),
+ id1=1.1,
+ id2=1.2,
+ key='value'
+ )
+
+ item = storage.find(id1=1.1)
+
+ item.update(key='value_new')
+ item = storage.find(id1=1.1)
+ assert item.key == 'value_new'
+
+ item.update(id1=1.11)
+ item = storage.find(id1=1.11)
+ assert item.key == 'value_new'
+
+ item.update(key=None)
+ item = storage.find(id1=1.11)
+ assert not item.key
+
+ item.update(key='value', key1='value1')
+ item = storage.find(id1=1.11)
+ assert item.key == 'value'
+ assert item.key1 == 'value1'
+
+ item.delete()
+ item = storage.find(id1=1.11)
+ assert not item
diff --git a/lib/storage/tests/test_file_storage.py b/lib/storage/tests/test_file_storage.py
new file mode 100644
index 0000000..7651e94
--- /dev/null
+++ b/lib/storage/tests/test_file_storage.py
@@ -0,0 +1,155 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import uuid
+import os
+
+from tornado.options import options
+
+from storage.backends.file import Index, MPinStorage
+from storage.tests.cases import (
+ simplekey_index_case,
+ multi_indexes_case,
+ multikey_indexes,
+ mixkey_indexes_case,
+ missing_key_indexes_case,
+ valid_string_date_case,
+ invalid_string_date_case,
+ no_date_case,
+ item_deletion_case,
+ keys_with_underscore_case,
+ update_item_case,
+)
+
+STORAGE_FILENAME = '/tmp/{0}.json'.format(uuid.uuid4().hex)
+
+
+def get_storage(*args, **kwargs):
+ options.fileStorageLocation = STORAGE_FILENAME
+ if os.path.isfile(STORAGE_FILENAME):
+ os.remove(STORAGE_FILENAME)
+ return MPinStorage(*args, **kwargs)
+
+
+def test_index():
+ index = Index(None, 'a_b', ['a', 'b'])
+
+ assert index.name == 'a_b'
+ assert index.fields == ['a', 'b']
+
+ obj = {
+ 'a': 1,
+ 'b': 2,
+ 'c': 3,
+ }
+
+ key = index._get_item_key(obj)
+ assert key == '1_2'
+
+ class Obj(object):
+ a = 1
+ b = 2
+ c = 3
+
+ key = index._get_item_key(Obj())
+ assert key == '1_2'
+
+ obj = {
+ 'b': 2,
+ 'c': 3,
+ }
+
+ key = index._get_item_key(obj)
+ assert not key
+
+ class Obj(object):
+ b = 2
+ c = 3
+
+ key = index._get_item_key(Obj())
+ assert not key
+
+
+def test_simplekey_index(io_loop):
+ storage = get_storage(io_loop, 'id1')
+ simplekey_index_case(storage)
+
+
+def test_multi_indexes(io_loop):
+ storage = get_storage(io_loop, 'id1', 'id2')
+ multi_indexes_case(storage)
+
+
+def test_multikey_indexes(io_loop):
+ storage = get_storage(io_loop, 'id1,id2')
+ multikey_indexes(storage)
+
+
+def test_mixkey_indexes(io_loop):
+ storage = get_storage(io_loop, 'id1', 'id1,id2')
+ mixkey_indexes_case(storage)
+
+
+def test_missing_key_indexes(io_loop):
+ storage = get_storage(io_loop, 'id1', 'id2')
+ missing_key_indexes_case(storage)
+
+
+def test_valid_string_date(io_loop):
+ storage = get_storage(io_loop, 'id1', 'id2')
+ valid_string_date_case(storage)
+
+
+def test_invalid_string_date(io_loop):
+ storage = get_storage(io_loop, 'id1', 'id2')
+ invalid_string_date_case(storage)
+
+
+def test_no_date(io_loop):
+ storage = get_storage(io_loop, 'id1', 'id2')
+ no_date_case(storage)
+
+
+def test_item_deletion(io_loop):
+ storage = get_storage(io_loop, 'id1', 'id2')
+ item_deletion_case(storage)
+
+
+def test_keys_with_underscore(io_loop):
+ storage = get_storage(io_loop, 'id_1', 'id_1,id_2')
+ keys_with_underscore_case(storage)
+
+
+def test_update_item(io_loop):
+ storage = get_storage(io_loop, 'id1')
+ update_item_case(storage)
+
+
+def test_reinitialization(io_loop):
+ storage = get_storage(io_loop, 'id1')
+ simplekey_index_case(storage)
+
+ del storage
+ storage = MPinStorage(io_loop, 'id1')
+
+ item1 = storage.find(id1=1.1)
+ item2 = storage.find(id1=2.1)
+
+ assert item1.key == 'value1'
+ assert item2.key == 'value2'
diff --git a/lib/storage/tests/test_get_storage.py b/lib/storage/tests/test_get_storage.py
new file mode 100644
index 0000000..2d05dfb
--- /dev/null
+++ b/lib/storage/tests/test_get_storage.py
@@ -0,0 +1,61 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import pytest
+
+from tornado.options import options
+
+from storage import get_storage_cls, StorageError
+from storage.backends.memory import MPinStorage as MemoryStorage
+from storage.backends.redis import MPinStorage as RedisStorage
+from storage.backends.file import MPinStorage as FileStorage
+
+
+def test_defauld(io_loop):
+ storage = get_storage_cls()(io_loop, 'key')
+ assert isinstance(storage, MemoryStorage)
+
+
+def test_memory_defauld(io_loop):
+ options.storage = 'memory'
+ storage = get_storage_cls()(io_loop, 'key')
+ assert isinstance(storage, MemoryStorage)
+
+
+def test_redis_defauld(io_loop):
+ options.storage = 'redis'
+ storage = get_storage_cls()(io_loop, 'key')
+ assert isinstance(storage, RedisStorage)
+
+
+def test_file_defauld(io_loop):
+ options.storage = 'json'
+ options.fileStorageLocation = None
+ with pytest.raises(StorageError):
+ storage = get_storage_cls()(io_loop, 'key')
+
+ options.fileStorageLocation = 'file.json'
+ storage = get_storage_cls()(io_loop, 'key')
+ assert isinstance(storage, FileStorage)
+
+
+def test_invalid_defauld(io_loop):
+ options.storage = 'invalid'
+ with pytest.raises(SystemExit):
+ get_storage_cls()(io_loop, 'key')
diff --git a/lib/storage/tests/test_memory_storage.py b/lib/storage/tests/test_memory_storage.py
new file mode 100644
index 0000000..326189e
--- /dev/null
+++ b/lib/storage/tests/test_memory_storage.py
@@ -0,0 +1,127 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+from storage.backends.memory import Index, MPinStorage
+from storage.tests.cases import (
+ simplekey_index_case,
+ multi_indexes_case,
+ multikey_indexes,
+ mixkey_indexes_case,
+ missing_key_indexes_case,
+ valid_string_date_case,
+ invalid_string_date_case,
+ no_date_case,
+ item_deletion_case,
+ keys_with_underscore_case,
+ update_item_case,
+)
+
+
+def test_index():
+ index = Index(None, 'a_b', ['a', 'b'])
+
+ assert index.name == 'a_b'
+ assert index.fields == ['a', 'b']
+
+ obj = {
+ 'a': 1,
+ 'b': 2,
+ 'c': 3,
+ }
+
+ key = index._get_item_key(obj)
+ assert key == '1_2'
+
+ class Obj(object):
+ a = 1
+ b = 2
+ c = 3
+
+ key = index._get_item_key(Obj())
+ assert key == '1_2'
+
+ obj = {
+ 'b': 2,
+ 'c': 3,
+ }
+
+ key = index._get_item_key(obj)
+ assert not key
+
+ class Obj(object):
+ b = 2
+ c = 3
+
+ key = index._get_item_key(Obj())
+ assert not key
+
+
+def test_simplekey_index(io_loop):
+ storage = MPinStorage(io_loop, 'id1')
+ simplekey_index_case(storage)
+
+
+def test_multi_indexes(io_loop):
+ storage = MPinStorage(io_loop, 'id1', 'id2')
+ multi_indexes_case(storage)
+
+
+def test_multikey_indexes(io_loop):
+ storage = MPinStorage(io_loop, 'id1,id2')
+ multikey_indexes(storage)
+
+
+def test_mixkey_indexes(io_loop):
+ storage = MPinStorage(io_loop, 'id1', 'id1,id2')
+ mixkey_indexes_case(storage)
+
+
+def test_missing_key_indexes(io_loop):
+ storage = MPinStorage(io_loop, 'id1', 'id2')
+ missing_key_indexes_case(storage)
+
+
+def test_valid_string_date(io_loop):
+ storage = MPinStorage(io_loop, 'id1', 'id2')
+ valid_string_date_case(storage)
+
+
+def test_invalid_string_date(io_loop):
+ storage = MPinStorage(io_loop, 'id1', 'id2')
+ invalid_string_date_case(storage)
+
+
+def test_no_date(io_loop):
+ storage = MPinStorage(io_loop, 'id1', 'id2')
+ no_date_case(storage)
+
+
+def test_item_deletion(io_loop):
+ storage = MPinStorage(io_loop, 'id1', 'id2')
+ item_deletion_case(storage)
+
+
+def test_keys_with_underscore(io_loop):
+ storage = MPinStorage(io_loop, 'id_1', 'id_1,id_2')
+ keys_with_underscore_case(storage)
+
+
+def test_update_item(io_loop):
+ storage = MPinStorage(io_loop, 'id1')
+ update_item_case(storage)
diff --git a/lib/storage/tests/test_redis_storage.py b/lib/storage/tests/test_redis_storage.py
new file mode 100644
index 0000000..416abbf
--- /dev/null
+++ b/lib/storage/tests/test_redis_storage.py
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import fakeredis
+
+from storage.backends.redis import MPinStorage
+from storage.tests.cases import (
+ simplekey_index_case,
+ multi_indexes_case,
+ multikey_indexes,
+ mixkey_indexes_case,
+ missing_key_indexes_case,
+ valid_string_date_case,
+ invalid_string_date_case,
+ no_date_case,
+ item_deletion_case,
+ keys_with_underscore_case,
+ update_item_case,
+)
+
+
+def get_storage(*args, **kwargs):
+ storage = MPinStorage(*args, **kwargs)
+ storage.redis.redis = fakeredis.FakeStrictRedis()
+ storage.redis.redis.flushdb()
+ return storage
+
+
+def test_simplekey_index(io_loop):
+ simplekey_index_case(get_storage(io_loop, 'id1'))
+
+
+def test_multi_indexes(io_loop):
+ multi_indexes_case(get_storage(io_loop, 'id1', 'id2'))
+
+
+def test_multikey_indexes(io_loop):
+ multikey_indexes(get_storage(io_loop, 'id1,id2'))
+
+
+def test_mixkey_indexes(io_loop):
+ mixkey_indexes_case(get_storage(io_loop, 'id1', 'id1,id2'))
+
+
+def test_missing_key_indexes(io_loop):
+ missing_key_indexes_case(get_storage(io_loop, 'id1', 'id2'))
+
+
+def test_valid_string_date(io_loop):
+ valid_string_date_case(get_storage(io_loop, 'id1', 'id2'))
+
+
+def test_invalid_string_date(io_loop):
+ invalid_string_date_case(get_storage(io_loop, 'id1', 'id2'))
+
+
+def test_no_date(io_loop):
+ no_date_case(get_storage(io_loop, 'id1', 'id2'))
+
+
+def test_item_deletion(io_loop):
+ item_deletion_case(get_storage(io_loop, 'id1', 'id2'))
+
+
+def test_keys_with_underscore(io_loop):
+ keys_with_underscore_case(get_storage(io_loop, 'id_1', 'id_1,id_2'))
+
+
+def test_update_item(io_loop):
+ update_item_case(get_storage(io_loop, 'id1'))
diff --git a/requirements/common.txt b/requirements/common.txt
new file mode 100644
index 0000000..b9a0df3
--- /dev/null
+++ b/requirements/common.txt
@@ -0,0 +1,5 @@
+cffi==0.8.6
+pbkdf2==1.3
+python-dateutil==2.4.2
+redis==2.10.3
+tornado==4.1
diff --git a/requirements/dev.txt b/requirements/dev.txt
new file mode 100644
index 0000000..4f4c6b7
--- /dev/null
+++ b/requirements/dev.txt
@@ -0,0 +1,11 @@
+-r common.txt
+
+diff-cover==0.8.6
+fakeredis==0.6.2
+freezegun==0.3.5
+pytest-cov==2.2.0
+pytest-flakes==1.0.1
+pytest-pep8==1.0.6
+pytest-tornado==0.4.4
+pytest==2.8.5
+tox==2.3.1
diff --git a/runtests.sh b/runtests.sh
new file mode 100755
index 0000000..3096f5c
--- /dev/null
+++ b/runtests.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+DIRECTORY="${1:-.}"
+
+py.test \
+ --cov lib \
+ --cov servers \
+ --cov-report term-missing \
+ --cov-report xml \
+ --no-cov-on-fail \
+ --flakes \
+ --pep8 \
+ "$DIRECTORY" || exit 1
+
+diff-cover --compare-branch master coverage.xml
diff --git a/scripts/change_ms_backup_passphrase.py b/scripts/change_ms_backup_passphrase.py
new file mode 100755
index 0000000..6cea99f
--- /dev/null
+++ b/scripts/change_ms_backup_passphrase.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Decrypt master secret backup and crypt it back with new passphrase."""
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import os
+from datetime import datetime
+from getpass import getpass
+
+from mpin_utils import secrets
+from mpin_utils.common import Seed
+
+DEFAULT_BACKUP_FILE = './backup.json'
+DEFAULT_ENTROPY_SOURCES = 'dev_urandom:100'
+
+
+def main():
+ """Main function."""
+ while True:
+ backup_file = raw_input('Path to M-Pin master secret backup file (Default: {0}): '.format(DEFAULT_BACKUP_FILE)) or DEFAULT_BACKUP_FILE
+ if not os.path.exists(backup_file):
+ print('No such file {0}'.format(backup_file))
+ else:
+ break
+
+ old_passphrase = getpass('Passphrase for {0}: '.format(backup_file))
+ salt = raw_input('SALT used for old encryption:') or ''
+ entropy_sources = raw_input('Entropy sources (Default: {0}): '.format(DEFAULT_ENTROPY_SOURCES)) or DEFAULT_ENTROPY_SOURCES
+
+ Seed.getSeed(entropy_sources)
+ seed = Seed.seedValue
+
+ secrets_obj = secrets.Secrets(
+ old_passphrase, salt, seed, datetime.now(), backup_file, True)
+
+ while True:
+ new_passphrase1 = getpass("Please enter passphrase: ")
+ new_passphrase2 = getpass("Please enter passphrase (again): ")
+
+ if new_passphrase1 == new_passphrase2:
+ new_passphrase = new_passphrase1
+ break
+ else:
+ print('Passphrases don\'t match')
+
+ secrets.backup_master_secret(
+ secrets_obj.master_secret, True, new_passphrase, salt, backup_file,
+ secrets_obj.start_time, secrets_obj.rng)
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/getCommunityCredentials.py b/scripts/getCommunityCredentials.py
new file mode 100755
index 0000000..6d80a8e
--- /dev/null
+++ b/scripts/getCommunityCredentials.py
@@ -0,0 +1,185 @@
+#! /usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import hashlib
+import hmac
+import json
+import os
+import sys
+import urllib2
+
+KEYS_ENDPOINT = "https://register.certivox.net/api/v3/platform/core"
+INSTALLER_KEY = "b4e62b5ae100dd51dfc0d910579b3311"
+DEFAULT_INSTALL_PATH = "/opt/mpin"
+INSTALLATION_TYPE = "mpincore"
+
+
+class Console(object):
+ BLUE = '\033[94m'
+ GREEN = '\033[92m'
+ YELLOW = '\033[93m'
+ RED = '\033[91m'
+ CLEAR = '\033[0m'
+
+ @staticmethod
+ def initConsole():
+ # Check for console colors support
+ if not sys.stdout.isatty():
+ Console.BLUE, Console.GREEN, Console.YELLOW, Console.RED, Console.CLEAR = ["" for _ in range(5)]
+
+ @staticmethod
+ def out(rawtext):
+ sys.stdout.write(rawtext)
+ sys.stdout.flush()
+
+ @staticmethod
+ def text(text, newLine=True):
+ sys.stdout.write("{0}{1}{2}".format(Console.CLEAR, text, newLine and "\n" or " "))
+ sys.stdout.flush()
+
+ @staticmethod
+ def info(text, newLine=True):
+ sys.stdout.write("{0}{1}{2}{3}".format(Console.GREEN, text, Console.CLEAR, newLine and "\n" or " "))
+ sys.stdout.flush()
+
+ @staticmethod
+ def blue(text, newLine=True):
+ sys.stdout.write("{0}{1}{2}{3}".format(Console.BLUE, text, Console.CLEAR, newLine and "\n" or " "))
+ sys.stdout.flush()
+
+ @staticmethod
+ def error(text="ERROR", newLine=True):
+ sys.stdout.write("{0}{1}{2}{3}".format(Console.RED, text, Console.CLEAR, newLine and "\n" or " "))
+ sys.stdout.flush()
+
+ @staticmethod
+ def fatal(text, prevLineStrip=False):
+ if prevLineStrip:
+ Console.error()
+ Console.error("*** {0}\n".format(text))
+ sys.exit(1)
+
+ @staticmethod
+ def done(text="Done", newLine=True):
+ sys.stdout.write("{0}{1}{2}".format(Console.CLEAR, text, newLine and "\n" or " "))
+ sys.stdout.flush()
+
+ @staticmethod
+ def ok(text="Ok", newLine=True):
+ Console.done(text)
+ sys.stdout.flush()
+
+ @staticmethod
+ def getInput(text, validateFunc=None, errorMessage=None):
+ while True:
+ try:
+ r_input = raw_input
+ except NameError:
+ r_input = input
+
+ try:
+ res = r_input("{0}{1}: ".format(Console.CLEAR, text))
+ except KeyboardInterrupt:
+ Console.fatal("Installation interrupted.")
+
+ if not validateFunc:
+ break
+ else:
+ v = validateFunc(res)
+ if v:
+ break
+ else:
+ if errorMessage:
+ Console.error(errorMessage)
+
+ return res
+
+
+def signMessage(message, key):
+ return hmac.new(key, message.encode('utf-8'), hashlib.sha256).hexdigest()
+
+
+def requestFreeKeys(user_contact):
+ method = "POST"
+ path = "getCommunityCredentials"
+ installation_type = INSTALLATION_TYPE
+ timestamp = datetime.datetime.utcnow().isoformat()
+ user_contact_hex = user_contact.encode("hex")
+ M = "{0}{1}{2}{3}{4}".format(method, path, installation_type, timestamp, user_contact_hex)
+ signature = signMessage(M, INSTALLER_KEY)
+
+ params = {
+ "method": method,
+ "path": path,
+ "installation_type": installation_type,
+ "timestamp": timestamp,
+ "user_contact_hex": user_contact_hex,
+ "signature": signature
+ }
+
+ try:
+ req = urllib2.Request(KEYS_ENDPOINT, data=json.dumps(params), headers={"Content-Type": "application/json"})
+ resp = urllib2.urlopen(req)
+ return True, json.dumps(json.loads(resp.read()), indent=4)
+ except urllib2.URLError, e:
+ try:
+ code = e.code
+ except:
+ code = ""
+ return False, "{0} {1}".format(code, e.reason)
+
+
+def getFreeKeys(installPath):
+
+ Console.text(
+ 'You can enter an email address or a Twitter account to be contacted on by our support team for '
+ 'full info on out to get the most value from M-Pin Core. ',
+ False
+ )
+ Console.text('To skip this step just hit enter, you can still contact us on support@miracl.com or on Twitter on @miraclhq\n')
+ user_contact = Console.getInput('Your email or Twitter account')
+
+ Console.text("\nGetting Community D-TA keys...", False)
+
+ ok, result = requestFreeKeys(user_contact)
+
+ if ok:
+ credentialsFile = os.path.join(installPath, "credentials.json")
+ try:
+ open(credentialsFile, "w").write(result)
+ Console.info("Done\n")
+ except Exception, e:
+ Console.error("Fail")
+ Console.error("Unable to write Community D-TA credentials file: {0}\n".format(e))
+ Console.text("Your credentials.json:")
+ Console.text(result)
+
+ else:
+ Console.error("Fail")
+ Console.error("Unable to get Community D-TA keys: {0}".format(result))
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ installPath = sys.argv[1]
+ else:
+ installPath = DEFAULT_INSTALL_PATH
+
+ getFreeKeys(installPath)
diff --git a/scripts/getServerSecretShare.py b/scripts/getServerSecretShare.py
new file mode 100755
index 0000000..f50dc51
--- /dev/null
+++ b/scripts/getServerSecretShare.py
@@ -0,0 +1,127 @@
+#! /usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import unicode_literals
+
+import datetime
+import hashlib
+import hmac
+import json
+import sys
+import traceback
+import urllib
+import urllib2
+
+SIGNATURE_EXPIRES_SECONDS = 60
+
+
+class ScriptException(Exception):
+ pass
+
+
+def sign_message(message, key):
+ return hmac.new(key, message.encode('utf-8'), hashlib.sha256).hexdigest()
+
+
+def get_arguments():
+ if len(sys.argv) < 2:
+ raise ScriptException('credentials.json required')
+
+ if len(sys.argv) > 2:
+ raise ScriptException('Unexpected number of arguments')
+
+ return sys.argv[1]
+
+
+def get_credentials(credentials_json):
+ """ Parses the content of the file.
+
+ Will raise exception if any of the required keys are missing.
+ """
+ try:
+ with open(credentials_json, 'r') as credentials_file:
+ credentials = json.loads(credentials_file.read())
+ except IOError:
+ raise ScriptException('Invalid filename')
+ except ValueError:
+ print credentials_file.read()
+ raise ScriptException('Invalid json (invalid formating)')
+
+ for key in ['app_id', 'app_key', 'api_url']:
+ if key not in credentials:
+ raise ScriptException('Invalid json ({} key missing)'.format(key))
+
+ return credentials
+
+
+def get_expiration_time():
+ expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=SIGNATURE_EXPIRES_SECONDS)
+ expires_str = expires.isoformat().split(".")[0] + "Z"
+ return expires_str
+
+
+def get_server_secret(credentials, expires):
+ """ Fetch server secret from CertiVox server """
+ path = 'serverSecret'
+ params = urllib.urlencode({
+ 'app_id': credentials['app_id'],
+ 'expires': expires,
+ 'signature': sign_message(
+ '{}{}{}'.format(path, credentials['app_id'], expires),
+ str(credentials['app_key'])
+ )
+ })
+
+ try:
+ response = urllib2.urlopen('{api_url}{end_point}?{params}'.format(
+ api_url=credentials['api_url'],
+ end_point=path,
+ params=params,
+ ))
+ except urllib2.HTTPError as e:
+ if e.code == 408:
+ print "Make sure your time it correct!"
+ raise ScriptException('Response code: {} - {}'.format(e.code, e.read()))
+
+ data = json.loads(response.read())
+ return data['serverSecret']
+
+
+def main():
+ credentials_filename = get_arguments()
+ credentials = get_credentials(credentials_filename)
+
+ # Make a request for CertiVox server secret share
+ server_secret = get_server_secret(credentials, get_expiration_time())
+
+ # Add to initial credential json and print to stdout
+ credentials['certivox_server_secret'] = server_secret
+ print json.dumps(credentials, indent=2)
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except ScriptException as e:
+ print(e)
+ except KeyboardInterrupt:
+ print "Shutdown requested...exiting"
+ except Exception:
+ traceback.print_exc(file=sys.stdout)
+ sys.exit(0)
diff --git a/servers/demo/__init__.py b/servers/demo/__init__.py
new file mode 100644
index 0000000..a6b3acf
--- /dev/null
+++ b/servers/demo/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License./
diff --git a/servers/demo/config_default.py b/servers/demo/config_default.py
new file mode 100644
index 0000000..0a7e4b0
--- /dev/null
+++ b/servers/demo/config_default.py
@@ -0,0 +1,69 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import unicode_literals
+
+"""HTTP server settings"""
+address = '0.0.0.0'
+port = 8005
+
+cookieSecret = '%COOKIESECRET%'
+
+"""CDN for mpin.js"""
+mpinJSURL = '%MPINJSURL%'
+
+"""RPS discovery options"""
+RPSURL = 'http://127.0.0.1:8011'
+# rpsPrefix = 'rps' # Default
+clientSettingsURL = '/rps/clientSettings'
+verifyIdentityURL = '/mpinActivate'
+
+"""Key value storage options"""
+# storage = 'memory'
+
+# storage = 'redis'
+# redisHost = '127.0.0.1' # Default
+# redisPort = 6379 # Default
+# redisDB = 0 # Default
+# redisPassword = None # Default
+# redisPrefix = 'mpin' # Default
+
+storage = 'json'
+fileStorageLocation = './mpin_demo_storage.json'
+
+"""Verification emails settings
+
+If forceActivate is True the demo site will activate new users without verifying them
+with email.
+"""
+forceActivate = True
+
+"""Email options"""
+emailSubject = 'New user activation'
+emailSender = ''
+smtpServer = ''
+smtpPort = 25
+smtpUser = ''
+smtpPassword = ''
+# smtpUseTLS = True
+
+"""Mobile app"""
+mobileAppPath = '%MOBILEAPPPATH%'
+mobileAppFullURL = '/m'
+
+"""Debug options"""
+# logLevel = "INFO"
diff --git a/servers/demo/demoservice.py b/servers/demo/demoservice.py
new file mode 100755
index 0000000..a4df34f
--- /dev/null
+++ b/servers/demo/demoservice.py
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import sys
+
+if (os.name != "nt"):
+ print "This file can be run on Windows."
+ sys.exit(1)
+
+import mpinDemo
+from mpWinService import installService
+
+
+installService(mpinDemo.ServiceDaemon, 'mpinDemo', 'M-Pin Demo Application Service')
diff --git a/servers/demo/mailer.py b/servers/demo/mailer.py
new file mode 100755
index 0000000..1647c31
--- /dev/null
+++ b/servers/demo/mailer.py
@@ -0,0 +1,83 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Mailer module, send mail from a separate thread
+import smtplib
+import os
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from threading import Thread
+from tornado import template
+
+smtpServer = ""
+smtpPort = 0
+senderAddress = ""
+useTLS = False
+
+
+# Render templates
+def render_template(template_name, **kwargs):
+ loader = template.Loader(os.path.dirname(os.path.realpath(__file__)) + "/templates")
+ t = loader.load(template_name)
+ return t.generate(**kwargs)
+
+
+# Initialize the mailer parameters
+def setup(smtpserver, smtpport, senderaddress, usetls):
+ global smtpServer, smtpPort, senderAddress, useTLS
+ smtpServer = smtpserver
+ smtpPort = smtpport
+ senderAddress = senderaddress
+ useTLS = usetls
+
+
+# The actual mail sending routine (which should be ran from sendActivationEmail() in a separate thread so that Tornado is not blocked)
+def mailerThread(recipientAddress, subject, deviceName, validationURL, user=None, password=None):
+ if not smtpServer:
+ return
+ msg = MIMEMultipart('alternative')
+ msg['Subject'] = subject
+ msg['From'] = senderAddress
+ msg['To'] = recipientAddress
+
+ # Text version (in case the mail client does not like the HTML part).
+ mailText = render_template('activation_email.txt', validationURL=validationURL)
+
+ # HTML version
+ mailHTML = render_template('activation_email.html', validationURL=validationURL)
+
+ mailPartText = MIMEText(mailText, 'plain')
+ mailPartHTML = MIMEText(mailHTML, 'html')
+ msg.attach(mailPartText)
+ msg.attach(mailPartHTML)
+
+ sender = smtplib.SMTP(smtpServer, smtpPort)
+
+ if useTLS:
+ sender.starttls()
+ sender.ehlo()
+
+ if user:
+ sender.login(user, password)
+
+ sender.sendmail(senderAddress, recipientAddress, msg.as_string())
+ sender.quit()
+
+
+def sendActivationEmail(recipientAddress, subject, deviceName, validationURL, user=None, password=None):
+ thread = Thread(target=mailerThread, args=(recipientAddress, subject, deviceName, validationURL, user, password))
+ thread.start()
diff --git a/servers/demo/mpinDemo.py b/servers/demo/mpinDemo.py
new file mode 100755
index 0000000..c3a3439
--- /dev/null
+++ b/servers/demo/mpinDemo.py
@@ -0,0 +1,702 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import datetime
+import json
+import os
+import sys
+import time
+import uuid
+from urlparse import urlparse
+
+import tornado.autoreload
+import tornado.escape
+import tornado.gen
+import tornado.httpclient
+import tornado.httpserver
+import tornado.ioloop
+import tornado.options
+import tornado.web
+from tornado.log import app_log as log
+from tornado.options import define, options
+
+import mailer
+from mpin_utils.common import detectProxy, getLogLevel, Time
+from storage import get_storage_cls
+
+if os.name == "posix":
+ from mpDaemon import Daemon
+elif os.name == "nt":
+ from mpWinService import Service as Daemon
+else:
+ raise Exception("Unsupported platform: {0}".format(os.name))
+
+BASE_DIR = os.path.dirname(__file__)
+CONFIG_FILE = os.path.join(BASE_DIR, "config.py")
+
+
+# OPTIONS
+
+# general options
+define("configFile", default=os.path.join(BASE_DIR, "config.py"), type=unicode)
+define("address", default="127.0.0.1", type=unicode)
+define("port", default=8005, type=int)
+define("cookieSecret", type=unicode)
+define("resourcesBasePath", default=BASE_DIR, type=unicode)
+define("mpinJSURL", default="", type=unicode)
+
+# debugging options
+define("autoReload", default=False, type=bool)
+define("logLevel", default="ERROR", type=unicode)
+define("forceActivate", default=False, type=bool)
+
+# RPS service discovery options
+define("RPSURL", default="", type=unicode)
+define("rpsPrefix", default="rps", type=unicode)
+define("clientSettingsURL", default="", type=unicode)
+define("verifyIdentityURL", default="", type=unicode)
+
+# authentication options
+define("requestOTP", default=False, type=bool)
+
+# email options
+define("emailSubject", type=unicode)
+define("emailSender", type=unicode)
+define("smtpServer", type=unicode)
+define("smtpPort", default=25, type=int)
+define("smtpUser", type=unicode)
+define("smtpPassword", type=unicode)
+define("smtpUseTLS", default=False, type=bool)
+
+# mobile
+define("mobileOnly", default=False, type=bool)
+define("mobileSupport", default=True, type=bool)
+define("mobileAppPath", default="", type=unicode)
+define("mobileAppFullURL", default="", type=unicode)
+
+define("mobileUseNative", default=False, type=bool)
+define("mobileConfigURL", default=None, type=unicode)
+
+
+# UTILITIES
+class MobileLoginHandler(object):
+ def __init__(self):
+ self.waiters = dict()
+
+ def waitForLogin(self, callback, sessionId, logout=False):
+ sid = logout and ("logout;%s" % sessionId) or sessionId
+ log.debug("Adding callback for session {0}".format(sid))
+ self.waiters[sid] = callback
+
+ def cancelWait(self, sessionId, logout=False):
+ sid = logout and ("logout;%s" % sessionId) or sessionId
+ if self.waiters.get(sid):
+ del self.waiters[sid]
+
+ def userLogged(self, sessionId, logout=False):
+ sid = logout and ("logout;%s" % sessionId) or sessionId
+ log.debug("WAITING CALLBACK FOR: {0}".format(sid))
+
+ callback = self.waiters.get(sid)
+ if callback:
+ callback()
+ if self.waiters.get(sid):
+ del self.waiters[sid]
+ return True
+ else:
+ log.debug("No Callback!")
+
+ return False
+mobileLoginHandler = MobileLoginHandler()
+
+
+def generateSessionID():
+ return uuid.uuid4().hex + uuid.uuid1().hex
+
+
+# BASE HANDLERS
+class BaseHandler(tornado.web.RequestHandler):
+ def set_default_headers(self):
+ self.set_header("Access-Control-Allow-Origin", "*")
+ self.set_header("Access-Control-Allow-Credentials", "true")
+ self.set_header("Access-Control-Allow-Methods", "GET,POST,HEAD,OPTIONS")
+ self.set_header("Access-Control-Allow-Headers", "Content-Type, Depth, User-Agent, X-File-Size, X-Requested-With, X-Requested-By, If-Modified-Since, X-File-Name, Cache-Control, Pragma, Expires, WWW-Authenticate")
+ # self.set_header("Connection", "keep-alive")
+
+ self.set_header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
+ self.set_header("Pragma", "no-cache")
+ self.set_header("Expires", "Sat, 26 Jul 1997 05:00:00 GMT")
+
+ def prepare(self):
+ self.sessionId = self.get_secure_cookie("mpindemo_session")
+
+ if not self.sessionId:
+ self.sessionId = generateSessionID()
+ log.debug("Generated new sessionId: {0}".format(self.sessionId))
+ self.set_secure_cookie("mpindemo_session", self.sessionId)
+ self.loggedUser = ""
+ else:
+ item = self.storage.find(key="s;{0}".format(self.sessionId))
+ self.loggedUser = item.value if item else None
+
+ def get_flash(self):
+ flash = self.get_secure_cookie("flash")
+ if flash:
+ self.clear_cookie("flash")
+ return flash
+
+ def set_flash(self, message):
+ self.set_secure_cookie("flash", message)
+
+ @property
+ def storage(self):
+ return self.application.storage
+
+ def finish(self, *args, **kwargs):
+ if self._status_code == 401:
+ log.error('asdasdasd')
+ self.set_header("WWW-Authenticate", "Authenticate")
+ super(BaseHandler, self).finish(*args, **kwargs)
+
+
+# APPLICATION HANDLERS
+class RPSRedirectHandler(BaseHandler):
+ SUPPORTED_METHODS = ['GET', 'POST', 'PUT', 'OPTIONS']
+
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def _processRequest(self, path):
+
+ url = "{0}/{1}".format(options.RPSURL.rstrip("/"), self.request.uri.strip("/"))
+ method = self.request.method
+ headers = self.request.headers
+ data = self.request.body
+
+ if (not data) and (method in ["GET", "OPTIONS"]):
+ data = None
+
+ client = tornado.httpclient.AsyncHTTPClient()
+
+ response = yield tornado.gen.Task(client.fetch, url, method=method, headers=headers, body=data)
+
+ for h in response.headers:
+ hV = response.headers.get(h)
+ if hV:
+ self.set_header(h, hV)
+
+ if (response.error):
+ if response.code > 500:
+ errorCode = 500
+ else:
+ errorCode = response.code
+ self.set_status(errorCode)
+ else:
+ self.set_status(response.code)
+
+ if response.body:
+ self.write(response.body)
+ self.finish()
+
+ def get(self, path):
+ return self._processRequest(path)
+
+ def post(self, path):
+ return self._processRequest(path)
+
+ def put(self, path):
+ return self._processRequest(path)
+
+ def options(self, path):
+ return self._processRequest(path)
+
+
+class IndexHandler(BaseHandler):
+
+ def get(self):
+ mobileAppURL = options.mobileAppFullURL
+
+ if not mobileAppURL.startswith("http"):
+ pr = urlparse(self.request.full_url())
+ mobileAppURL = "{0}://{1}{2}".format(pr.scheme, pr.netloc, mobileAppURL or "/m")
+
+ params = {
+ "clientSettingsURL": options.clientSettingsURL,
+ "mobileAppFullURL": mobileAppURL,
+ "mpinJSURL": options.mpinJSURL,
+ "logoutWaitURL": "/logoutWait",
+ "user": self.loggedUser,
+ "mobileOnly": options.mobileOnly and "true" or "false",
+ "mobileSupport": options.mobileSupport and "true" or "false",
+ "mobileUseNative": options.mobileUseNative,
+ "mobileConfigURL": options.mobileConfigURL
+ # "emailCheckRegex": "[0-9a-zA-Z]+"
+ }
+
+ templateName = self.request.path == "/login" and "login.html" or "index.html"
+
+ self.render(templateName, flash=self.get_flash(), **params)
+
+
+class VerifyUserHandler(BaseHandler):
+
+ def _generateValidationURL(self, base_url, identity, signature, expires):
+ return "{0}?i={1}&e={2}&s={3}".format(base_url, identity, expires, signature)
+
+ def post(self):
+ self.content_type = 'application/json'
+
+ try:
+ data = json.loads(self.request.body)
+ identity = data["mpinId"]
+ userid = data["userId"]
+ expireTime = data["expireTime"]
+ activateKey = data["activateKey"]
+ mobile = data["mobile"]
+ except ValueError:
+ log.error("Cannot decode body as JSON.")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+
+ userId = data.get("userId")
+ if not userId:
+ log.error("Missing userId")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID USER ID")
+ self.finish()
+ return
+
+ if options.verifyIdentityURL.startswith("/"): # relative path
+ base_url = "{0}/{1}".format(
+ self.request.headers.get("RPS-BASE-URL").rstrip("/"),
+ options.verifyIdentityURL.lstrip("/")
+ )
+ else:
+ base_url = options.verifyIdentityURL
+
+ validateURL = self._generateValidationURL(base_url, identity, activateKey, expireTime)
+ log.info("Sending activation email for user {0}: {1}".format(userid.encode("utf-8"), validateURL))
+
+ deviceName = mobile and "Mobile" or "PC"
+
+ if options.forceActivate:
+ log.warning("forceActivate option set! User activated without verification!")
+ else:
+ mailer.sendActivationEmail(userid.encode("utf-8"), options.emailSubject, deviceName, validateURL, options.smtpUser, options.smtpPassword)
+ log.warning("Sending Mail!")
+
+ responseData = {
+ "forceActivate": options.forceActivate
+ }
+ self.write(json.dumps(responseData))
+
+ self.set_status(200)
+ self.finish()
+
+
+class mpinPermitUserHandler(BaseHandler):
+
+ def get(self):
+
+ # The revocation handler
+ # When the RPS option RPAPermitUserURL is set
+ # It will make a request for validating the identity
+ # Before giving the time permit share to the client
+
+ self.content_type = 'application/json'
+
+ # If you return 403 it will show Unauthoirized message inside the PinPad
+ # self.set_status(403)
+
+ self.set_status(200) # We give permissions to everyone
+ self.finish()
+
+
+class mpinActivateHandler(BaseHandler):
+ def _verifySignature(self):
+ identity = self.get_argument("i", default="")
+ expires = self.get_argument("e", default="")
+ signature = self.get_argument("s", default="")
+
+ log.debug("/mpinActivate request for identity: {0}".format(identity))
+
+ try:
+ data = json.loads(identity.decode("hex"))
+ userid = data["userID"]
+ issued = data["issued"]
+ sIssued = Time.DateTimetoHuman(Time.ISOtoDateTime(issued))
+
+ mobile = int(data.get("mobile") or 0)
+
+ except Exception as E:
+ log.error("Error parsing the verification email: {0}".format(E))
+ userid, issued, sIssued = None, None, None
+
+ if userid:
+ if expires < datetime.datetime.utcnow().isoformat(b"T").split(".")[0] + "Z":
+ isValid = False
+ info = "Link expired"
+ else:
+ isValid = True
+ info = ""
+
+ deviceName = mobile and "Mobile" or "PC"
+
+ else:
+ log.error("/mpinActivate: Invalid IDENTITY: {0}".format(identity))
+ isValid, info = False, "Invalid identity"
+ deviceName, issued = "", ""
+
+ params = {
+ "isValid": isValid,
+ "identity": identity,
+ "errorMessage": info,
+ "userid": userid,
+ "issued": issued,
+ "humanIssued": sIssued,
+ "activated": False,
+ "deviceName": deviceName,
+ "activateKey": signature
+ }
+
+ return params
+
+ def get(self):
+ params = self._verifySignature()
+ self.render("activate.html", **params)
+
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def post(self):
+ params = self._verifySignature()
+
+ if params['isValid']:
+ mpinId = params["identity"]
+
+ url = "{0}/user/{1}".format(options.RPSURL.rstrip("/"), mpinId)
+
+ data = json.dumps({"activateKey": params["activateKey"]})
+ client = tornado.httpclient.AsyncHTTPClient()
+ response = yield tornado.gen.Task(client.fetch, url, method="POST", body=data)
+
+ if (response.error):
+ log.error("URL: {0}: Error: {1}".format(url, response.error))
+
+ params["isValid"] = False
+ params["errorMessage"] = "This verification link has already been used or has expired!"
+ else:
+ params["activated"] = True
+
+ self.render("activate.html", **params)
+
+
+class AuthenticateUserHandler(BaseHandler):
+
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def post(self):
+ self.content_type = 'application/json'
+
+ try:
+ data = json.loads(self.request.body)
+ authOTT = data["mpinResponse"]["authOTT"]
+ except ValueError:
+ log.error("Cannot decode body as JSON.")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+ except KeyError:
+ log.error("Invalid JSON structure.")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+
+ url = "{0}/authenticate".format(options.RPSURL.rstrip("/"))
+ client = tornado.httpclient.AsyncHTTPClient()
+ reqData = {
+ "authOTT": authOTT,
+
+ "logoutData": {"sessionToken": self.sessionId}
+ }
+ response = yield tornado.gen.Task(client.fetch, url, method="POST", body=json.dumps(reqData))
+
+ try:
+ data = json.loads(response.body)
+ status = data["status"]
+ userId = data["userId"]
+ message = data["message"]
+ except:
+ log.error("Invalid data from RPS: {1}: {0}".format(url, response.body))
+ message = "Server error"
+ status = 500
+
+ if (status == 200):
+ # The Revocation check based on userId or mpinId can be performed here
+ # The new status can be
+ # 200 - Login successfull
+ # 401 - Invalid PIN
+ # 403 - User not authorized. Login denied without deleting the client's token.
+ # 408 - The authentication has been expired.
+ # 410 - Login denied permanently. Will delete the client's token.
+
+ # If the RPS waitLoginResult option is set, /loginResult request must be made
+ # It can contain logoutData and logoutURL for mobile Logout functionality
+
+ url = "{0}/loginResult".format(options.RPSURL.rstrip("/"))
+ client = tornado.httpclient.AsyncHTTPClient()
+ reqData = {
+ "authOTT": authOTT,
+ "status": status,
+ "message": message,
+ "logoutData": {"sessionToken": self.sessionId, "userId": userId}
+ }
+ response = yield tornado.gen.Task(client.fetch, url, method="POST", body=json.dumps(reqData))
+
+ if status == 200:
+ # Login user. Update session information
+ session = self.sessionId
+ if session:
+ key = "s;{0}".format(session)
+ item = self.storage.find(key=key)
+ if item:
+ item.update(value=userId)
+ else:
+ self.storage.add(
+ key=key,
+ value=userId,
+ expire_time=datetime.datetime.now() + datetime.timedelta(seconds=3600)
+ )
+
+ self.set_status(status, message)
+ # Optional data can be send to the client's javascript handler.
+ if options.requestOTP:
+ ttlSeconds = 60
+ nowtm = int(time.mktime(datetime.datetime.utcnow().timetuple()) * 1000)
+ returnData = {
+ "expireTime": nowtm + ttlSeconds * 1000,
+ "ttlSeconds": ttlSeconds,
+ "nowTime": nowtm
+ }
+ else:
+ returnData = {"someUserData": "This will be handled by onSuccessLogin handler."}
+
+ self.write(returnData)
+ self.finish()
+
+
+class AboutHandler(BaseHandler):
+ def get(self):
+ self.redirect("http://www.miracl.com/miracl-product-m-pin-core")
+
+
+class ProtectedHandler(BaseHandler):
+ def get(self, protected_page=None):
+ if not self.loggedUser:
+ self.set_flash("protected")
+ self.redirect("/")
+ else:
+ template_name = "protected_{}.html".format(protected_page) if protected_page else "protected.html"
+ self.render(template_name, welcome=(self.get_flash() == "login"), user=self.loggedUser, logoutWaitURL="/logoutWait")
+
+
+class LogoutHandler(BaseHandler):
+ def get(self):
+ item = self.storage.find(key="s;{0}".format(self.sessionId))
+ if item:
+ item.delete()
+ self.clear_cookie("mpindemo_session")
+ self.redirect("/")
+
+ def post(self):
+ data = tornado.escape.json_decode(self.request.body)
+ sessionId = data.get("sessionToken")
+
+ log.debug("Logout request. Session token: {0}".format(sessionId))
+
+ item = self.storage.find(key="s;{0}".format(sessionId))
+ loggedUser = item.value if item else None
+
+ if (data.get("userId") != loggedUser):
+ log.debug(" The logged user {0} does not match the requested user {1}".format(loggedUser, data.get("userId")))
+ self.set_status(400, "Logout failed")
+ return
+
+ mobileLoginHandler.userLogged(sessionId, True)
+ item = self.storage.find(key="s;{0}".format(sessionId))
+ if item:
+ item.delete()
+
+ def options(self):
+ self.set_status(200)
+ self.finish()
+
+
+class LogoutWaitHandler(BaseHandler):
+ @tornado.web.asynchronous
+ def get(self):
+ '''Wait for user logout'''
+ mobileLoginHandler.waitForLogin(self.onUserLoggedOut, self.sessionId, True)
+
+ def onUserLoggedOut(self):
+ if self.request.connection.stream.closed():
+ return
+ self.set_flash("forced_logout")
+ self.write("OK")
+ self.finish()
+
+ def on_connection_close(self):
+ mobileLoginHandler.cancelWait(self.sessionId, True)
+
+
+class ServeMobileFileHandler(tornado.web.StaticFileHandler):
+ def set_extra_headers(self, path):
+ if path == "mpin.appcache":
+ self.set_header("Content-Type", "text/cache-manifest")
+
+
+class NotFoundHandler(BaseHandler):
+ def get(self, *args):
+ self.render("404.html")
+
+
+# MAIN
+class Application(tornado.web.Application):
+ def __init__(self):
+ staticPath = os.path.join(options.resourcesBasePath, "public")
+ templatesPath = os.path.join(options.resourcesBasePath, "templates")
+ mobilePath = options.mobileAppPath
+
+ if mobilePath:
+ handlers = [
+ (r'/m', tornado.web.RedirectHandler, {"url": "/m/index.html"}),
+ (r'/m/', tornado.web.RedirectHandler, {"url": "/m/index.html"}),
+ (r'/m/(.*)', ServeMobileFileHandler, {'path': mobilePath}),
+ ]
+ else:
+ handlers = []
+
+ handlers.extend([
+ (r"/", IndexHandler),
+ (r"/login", IndexHandler),
+
+ # M-PIN handlers
+ (r"/{0}/(.*)".format(options.rpsPrefix), RPSRedirectHandler),
+ (r"/mpinVerify", VerifyUserHandler),
+ (r"/mpinAuthenticate", AuthenticateUserHandler),
+ (r"/mpinActivate", mpinActivateHandler),
+ (r"/mpinPermitUser", mpinPermitUserHandler),
+
+ # Application handlers
+ (r"/protected/(.*)", ProtectedHandler),
+ (r"/protected", ProtectedHandler),
+ (r"/about", AboutHandler),
+ (r"/logout", LogoutHandler),
+ (r"/logoutWait", LogoutWaitHandler),
+
+ ])
+
+ if os.path.exists(os.path.join(templatesPath, "404.html")):
+ handlers.extend([(r"/(.*)", NotFoundHandler)])
+
+ settings = {
+ "template_path": templatesPath,
+ "static_path": staticPath,
+ "static_url_prefix": "/public/",
+ "cookie_secret": options.cookieSecret,
+ "xsrf_cookies": False
+ }
+
+ super(Application, self).__init__(handlers, **settings)
+
+ storage_cls = get_storage_cls()
+ self.storage = storage_cls(tornado.ioloop.IOLoop.instance(), 'key')
+
+
+def main():
+ options.parse_command_line()
+
+ if os.path.exists(options.configFile):
+ try:
+ options.parse_config_file(options.configFile)
+ options.parse_command_line()
+ except Exception, E:
+ print("Invalid config file {0}".format(options.configFile))
+ print(E)
+ sys.exit(1)
+
+ # Set Log level
+ log.setLevel(getLogLevel(options.logLevel))
+
+ if not options.cookieSecret:
+ log.error("cookieSecret option required")
+ sys.exit(1)
+
+ detectProxy()
+ mailer.setup(options.smtpServer, options.smtpPort, options.emailSender, options.smtpUseTLS)
+
+ log.info("Server starting on {0}:{1}...".format(options.address, options.port))
+
+ http_server = Application()
+ http_server.listen(options.port, options.address, xheaders=True)
+ io_loop = tornado.ioloop.IOLoop.instance()
+
+ if options.autoReload:
+ log.debug("Starting autoreloader")
+
+ tornado.autoreload.watch(CONFIG_FILE)
+ for f in os.listdir(http_server.settings["template_path"]):
+ fn = os.path.join(http_server.settings["template_path"], f)
+ if os.path.isfile(fn):
+ tornado.autoreload.watch(fn)
+ tornado.autoreload.start(io_loop)
+
+ log.info("Server started. Listening on {0}:{1}".format(options.address, options.port))
+ io_loop.start()
+
+
+class ServiceDaemon(Daemon):
+ def run(self):
+ main()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1].lower() in ("start", "stop"):
+ action = sys.argv.pop(1)
+ logFile = os.path.join(BASE_DIR, "mpinDemo.log")
+ pidFile = os.path.join(BASE_DIR, "mpinDemo.pid")
+
+ daemon = ServiceDaemon(pidfile=pidFile, stdout=logFile, stderr=logFile)
+ if action == "start":
+ log.info("Starting as daemon. Log file: {0}".format(logFile))
+ daemon.start()
+ elif action == "stop":
+ log.info("Stopping daemon...")
+ daemon.stop()
+ sys.exit()
+ else:
+ try:
+ main()
+ except Exception as e:
+ log.error(e)
+ sys.exit(1)
diff --git a/servers/demo/public/css/reset.css b/servers/demo/public/css/reset.css
new file mode 100644
index 0000000..905a8ce
--- /dev/null
+++ b/servers/demo/public/css/reset.css
@@ -0,0 +1,49 @@
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+margin: 0;
+padding: 0;
+border: 0;
+font-size: 100%;
+font: inherit;
+vertical-align: baseline;
+}
+
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+display: block;
+}
+
+body {
+line-height: 1;
+}
+
+ol, ul {
+list-style: none;
+}
+
+blockquote, q {
+quotes: none;
+}
+
+blockquote:before, blockquote:after,
+q:before, q:after {
+content: '';
+content: none;
+}
+
+table {
+border-collapse: collapse;
+border-spacing: 0;
+}
diff --git a/servers/demo/public/css/styles.css b/servers/demo/public/css/styles.css
new file mode 100644
index 0000000..663490c
--- /dev/null
+++ b/servers/demo/public/css/styles.css
@@ -0,0 +1,478 @@
+body {
+ font-family: 'Open Sans', Arial, Helvetica, sans-serif;
+ color: #36424a;
+ font-size: 1em;
+ overflow-y: scroll;
+}
+
+a {
+ color: #4891dc;
+ text-decoration: underline;
+}
+
+a:hover {
+ color: #f68428;
+}
+
+span.support {
+ width: 33.33%;
+ color: #3F4A52;
+ padding: 0px;
+ margin: 0px;
+ float: left;
+ font-size: 0.88em;
+ text-decoration: none;
+ padding: 10px 0px
+}
+
+.nav ul li span.support a {
+ width: auto;
+ float: none;
+ padding-left: 5px;
+ font-size: 1em;
+}
+
+.nav ul li span.support a:hover {
+ background-color: #ffffff
+}
+
+input {
+ height: 24px;
+ width: 200px;
+ margin: 0px 10px;
+ font-size: 16px;
+ vertical-align: middle;
+}
+
+#header {
+ padding: 10px 0px 10px;
+ border-bottom: 1px solid #CFCFCF;
+}
+
+.container {
+ max-width: 980px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.logo1 {
+ width: 49.9%;
+ float: left;
+}
+
+.logo1 img {
+ margin: 5px 0px 0px;
+}
+
+.logo2 {
+ width: 49.9%;
+ text-align: right;
+ float: left;
+}
+
+.clear {
+ clear: both;
+}
+
+.nav {
+ border-bottom: 1px solid #CFCFCF;
+ text-align: center;
+}
+
+.nav ul {
+ padding: 0px;
+ margin: 0px;
+ width: 100%
+}
+
+.nav ul li {
+ display: inline;
+}
+
+.nav ul li a {
+ width: 33.33%;
+ color: #4891dc;
+ padding: 0px;
+ margin: 0px;
+ float: left;
+ font-size: 0.88em;
+ text-decoration: none;
+ padding: 10px 0px
+}
+
+.nav ul li a:hover {
+ color: #f68428;
+ background-color: #edf0ee;
+}
+
+.nav ul li a.active {
+ color: #f68428;
+}
+
+.content h1 {
+ color: #4891dc;
+ text-align: center;
+ font-size: 2.5em;
+}
+
+.content h2 {
+ color: #363C74;
+ margin-top: 0px
+}
+
+.content p {}
+
+.column {
+ float: left;
+ padding: 20px;
+}
+
+.one {
+ width: 93.5%;
+ margin: 10px;
+}
+
+.two {
+ width: 43%;
+ margin: 10px;
+}
+
+.imageList {
+ margin-top: 0px;
+ padding-bottom: 0px;
+ padding-top: 0px;
+}
+
+.imageList li {
+ text-align: left
+}
+
+.center {
+ text-align: center
+}
+
+.leftAlign {
+ text-align: left;
+}
+
+.marBot10 {
+ margin-bottom: 10px;
+}
+
+.marBot20 {
+ margin-bottom: 20px;
+}
+
+.secret {
+ background-color: #FFFFFF;
+ color: grey;
+ font-size: 10px;
+ padding: 3px;
+ width: 80%;
+ word-wrap: break-word;
+}
+
+.grey {
+ background-color: ##edf0ee;
+}
+
+.dkRed {
+ color: #900;
+}
+
+.dkPurple {
+ color: #63C;
+}
+
+.dkBlue {
+ color: #039;
+}
+
+.dkGreen {
+ color: #063;
+}
+
+.error {
+ color: red;
+ font-weight: bold;
+}
+
+.grey p {
+ color: #767676;
+ font-size: 0.88em;
+}
+
+.grey p label {
+ color: #36424a;
+}
+
+section {
+ margin-bottom: 10px;
+ text-align: left;
+}
+
+#footer {
+ text-align: center;
+ font-size: 0.6em;
+ color: #36424a;
+ border-top: 1px solid #CFCFCF;
+ padding-top: 10px;
+ margin-top: 15px;
+}
+
+.instruction {
+ border: 1px solid #767676;
+ float: left;
+ display: block;
+ background-color: white;
+ width: 230px;
+ margin: 60px 0px 0px 20px;
+ padding: 0px 15px 15px;
+}
+
+
+/*#Buttons */
+
+.button,
+button,
+input[type="submit"],
+input[type="reset"],
+input[type="button"] {
+ margin: 0 5px;
+ color: #fff;
+ background: #4891dc;
+ border: 0 none;
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px;
+ color: #fff display: inline-block;
+ font-size: 11px;
+ font-weight: bold;
+ text-decoration: none;
+ cursor: pointer;
+ margin-bottom: 0px;
+ line-height: normal;
+ padding: 8px 10px;
+ font-family: "Open Sans", Helvetica, Arial, sans-serif;
+}
+
+.button:hover,
+button:hover,
+input[type="submit"]:hover,
+input[type="reset"]:hover,
+input[type="button"]:hover {
+ background: #f68428;
+ border: 0 none;
+}
+
+.button:active,
+button:active,
+input[type="submit"]:active,
+input[type="reset"]:active,
+input[type="button"]:active {
+ background: #f68428;
+ border: 0 none;
+}
+
+.button.full-width,
+button.full-width,
+input[type="submit"].full-width,
+input[type="reset"].full-width,
+input[type="button"].full-width {
+ width: 100%;
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+ text-align: center;
+}
+
+
+/* Fix for odd Mozilla border & padding issues */
+
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+.larger {
+ font-size: 1.5em;
+ padding: 15px 20px;
+ -moz-border-radius: 10px;
+ -webkit-border-radius: 10px;
+ border-radius: 10px;
+}
+
+
+/*css for hubspot js form */
+
+.content .page-header {
+ color: #2F2F2F;
+ float: none;
+ font-family: 'Open Sans', Tahoma, Geneva, sans-serif;
+ font-size: 1.5em;
+ letter-spacing: 0.1em;
+ margin: 0px auto 20px;
+ text-align: center;
+ text-transform: uppercase;
+ width: 980px;
+ border-top: 1px solid #CFCFCF;
+ padding-top: 20px;
+}
+
+.content .page-header h1 {
+ font-size: 44px;
+ line-height: 1.3em;
+ margin-bottom: 80px;
+ margin-top: 80px;
+ width: 470px;
+ color: #36424a;
+ font-family: 'Open Sans', Trebuchet MS, sans-serif;
+ font-weight: 300;
+ float: left;
+}
+
+.section-subheader {
+ padding-bottom: 20px;
+ padding-top: 20px;
+ font-size: 18px;
+ font-weight: 300;
+ line-height: 24px;
+ display: block;
+ text-align: center;
+ color: #36424a;
+ font-family: 'Open Sans', Trebuchet MS, sans-serif;
+}
+
+.blue-box-holder {
+ background-color: #6e8878;
+ color: #FFFFFF;
+ display: block;
+ float: left;
+ padding: 0px 23px 10px;
+}
+
+.blue-box-holder .hs-form label {
+ color: #FFFFFF;
+ float: left;
+ font-size: 0.9em;
+ font-weight: bold;
+ letter-spacing: normal;
+ margin: 20px 20px 5px;
+ text-align: left;
+ text-transform: none;
+ width: 280px;
+}
+
+.blue-box-holder .hs-form .input {
+ width: 300px;
+ float: left;
+ margin-left: 0px;
+ margin-top: 20px;
+}
+
+.blue-box-holder .hs-form .hs-button {
+ border: 1px solid #0F4059 !important;
+ padding: 8px 14px !important;
+ background-color: #6e8878;
+ color: #FFFFFF;
+ -moz-user-select: none !important;
+ background-color: #6e8878 !important;
+ background-image: -moz-linear-gradient(center top, #6e8878, #166086) !important;
+ border: 2px solid #0F4059 !important;
+ border-radius: 4px 4px 4px 4px !important;
+ box-shadow: 0 1px #25A0DF inset !important;
+ color: #FFFFFF !important;
+ cursor: pointer !important;
+ display: inline-block !important;
+ font-family: 'Open Sans', Trebuchet MS, sans-serif !important;
+ font-size: 16px !important;
+ font-weight: bold !important;
+ height: auto !important;
+ line-height: 24px !important;
+ padding: 4px 12px !important;
+ text-align: center !important;
+ text-decoration: none !important;
+ text-shadow: 0 -1px #092635 !important;
+ width: auto !important;
+ font-size: 20px !important;
+ padding: 15px 25px !important;
+ float: left;
+}
+
+.blue-box-holder .hs-form .hs-button:hover {
+ border: 1px solid #124C6B !important;
+ background-color: #218CC4 !important;
+ background-image: -moz-linear-gradient(center top, #218CC4, #166086) !important;
+ border: 2px solid #124C6B !important;
+ box-shadow: 0 1px #28ACF1 inset, 0 1px 8px rgba(0, 0, 0, 0.3) !important;
+ color: #FFFFFF !important;
+}
+
+.blue-box-holder .hs-form .hs_submit {
+ float: left;
+ margin: 0px;
+ padding: 0px;
+}
+
+.blue-box-holder .hs-form .smart-field {
+ display: block;
+ width: 640px;
+ float: left;
+}
+
+.blue-box-holder .hs-form .actions {
+ margin: 7px 0px;
+ padding: 0px;
+ float: left;
+ clear: both;
+}
+
+.blue-box-holder .hs-form label {
+ color: #FFFFFF;
+ float: left;
+ font-size: 0.8em;
+}
+
+
+/* max-width */
+
+@media screen and (max-width: 480px) {
+ .logo1 {
+ display: none;
+ }
+ .logo2 {
+ text-align: center;
+ width: 100%
+ }
+ .nav ul li a {
+ width: 100%;
+ border-bottom: 1px solid #CFCFCF;
+ }
+ .nav {
+ border-bottom: 0px
+ }
+ .column {
+ float: none;
+ }
+ .two {
+ width: 95%;
+ padding: 10px 0px;
+ }
+}
+
+#loggedInHolder {
+ color: #36424a;
+ background-color: #edf0ee;
+ border-bottom: 1px solid #CFCFCF;
+ margin: 0px;
+ padding: 0px;
+}
+
+#loggedInHolder .loggedInStatus {
+ width: 980px;
+ margin-left: auto;
+ margin-right: auto;
+ text-align: right;
+ font-size: 0.75em;
+ padding: 7px 2px
+}
diff --git a/servers/demo/public/images/blank-logo.svg b/servers/demo/public/images/blank-logo.svg
new file mode 100644
index 0000000..7e6d2cb
--- /dev/null
+++ b/servers/demo/public/images/blank-logo.svg
@@ -0,0 +1,25 @@
+<?xml version="1.0"?>
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="1000"
+ height="600"
+ viewBox="0 0 5 3">
+
+ <defs>
+ <clipPath id="a">
+ <rect
+ width="5"
+ height="3" />
+ </clipPath>
+ </defs>
+
+ <rect
+ width="5"
+ height="3"
+ fill="#fff" />
+ <path
+ d="M 0,0 L 5,3 M 0,3 L 5,0"
+ stroke="#ccc"
+ stroke-width=".3"
+ clip-path="url(#a)" />
+</svg>
diff --git a/servers/demo/public/images/favicon.ico b/servers/demo/public/images/favicon.ico
new file mode 100644
index 0000000..3f5c556
--- /dev/null
+++ b/servers/demo/public/images/favicon.ico
Binary files differ
diff --git a/servers/demo/public/images/landing-page.png b/servers/demo/public/images/landing-page.png
new file mode 100644
index 0000000..216748b
--- /dev/null
+++ b/servers/demo/public/images/landing-page.png
Binary files differ
diff --git a/servers/demo/public/images/milagro-logo.svg b/servers/demo/public/images/milagro-logo.svg
new file mode 100644
index 0000000..2e5fded
--- /dev/null
+++ b/servers/demo/public/images/milagro-logo.svg
@@ -0,0 +1,40 @@
+<svg version="1.1"
+ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
+ x="0px" y="0px" width="581.8px" height="117px" viewBox="0 0 581.8 117" style="enable-background:new 0 0 581.8 117;"
+ xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#3E454C;}
+ .st1{fill:#C2FF17;}
+ .st2{fill:#31BCA4;}
+ .st3{fill:#40D37B;}
+</style>
+<defs>
+</defs>
+<g>
+ <path class="st0" d="M179.1,95h-6.9L148,34.1V95h-9.9V22.9h15.7l21.9,56.3L198,22.9h15.7V95h-9.9V34.1L179.1,95z"/>
+ <path class="st0" d="M230.6,95V22.9h9.9V95H230.6z"/>
+ <path class="st0" d="M257.5,95V22.9h9.9v63.2h31.3V95H257.5z"/>
+ <path class="st0" d="M354,95l-5.7-15.3h-30.7L312.1,95h-10.8l27.9-72.1h8.7L365.4,95H354z M321,70.8h24.2l-12.1-32.9H333L321,70.8z
+ "/>
+ <path class="st0" d="M408.6,63.9V55h28.2v27.4c-6.9,8.8-17.7,13.8-29.6,13.8c-22,0-38.7-14.9-38.7-37.3c0-21.3,17.4-37.3,38.7-37.3
+ c11.1,0,21,4.9,27.9,12.8l-7,5.8c-5-5.7-12.7-9.7-20.9-9.7C391,30.5,379,42.5,379,58.9c0,17.5,11.7,28.4,28.2,28.4
+ c7.6,0,14.6-2.7,19.7-7.5v-16H408.6z"/>
+ <path class="st0" d="M491.2,95l-21.6-30.5h-7.4V95h-9.9V22.9h21.4c11,0,24.6,5.8,24.6,20.7c0,12.3-8.8,18.4-17.6,20.3L503.6,95
+ H491.2z M473.2,31.8h-11v23.9h9c9.6,0,16.7-3.5,16.7-11.7C487.8,36.6,481.9,31.8,473.2,31.8z"/>
+ <path class="st0" d="M544.8,96.2c-21,0-37.1-16.7-37.1-37.3c0-20.6,16.1-37.3,37.1-37.3c21,0,37.1,16.7,37.1,37.3
+ C581.8,79.5,565.8,96.2,544.8,96.2z M544.8,30.5c-15.3,0-26.6,12.5-26.6,28.4s11.2,28.4,26.6,28.4s26.6-12.5,26.6-28.4
+ S560.1,30.5,544.8,30.5z"/>
+</g>
+<g>
+ <path class="st1" d="M85.9,105.4L1.8,66.2C0.7,65.7,0,64.6,0,63.4v-49c0-2.3,2.4-3.8,4.4-2.8l84.1,39.2c1.1,0.5,1.8,1.6,1.8,2.8v49
+ C90.3,104.9,88,106.4,85.9,105.4z"/>
+ <g>
+ <path class="st2" d="M88.8,52.3L0,93.7l0,20.2c0,2.3,2.4,3.8,4.4,2.8l84.1-39.2c1.1-0.5,1.8-1.6,1.8-2.8V53.6
+ c0-0.7-0.2-1.3-0.5-1.8l0,0l0,0c0,0,0,0,0,0l0,0c-0.2-0.4-0.6-0.6-1-0.9c0.2,0.2,0.3,0.4,0.3,0.7C89.1,51.9,89,52.1,88.8,52.3z"/>
+ </g>
+ <g>
+ <path class="st3" d="M1.6,64.7l88.8-41.4V3.1c0-2.3-2.4-3.8-4.4-2.8L1.8,39.5C0.7,40,0,41.1,0,42.3v21.1c0,0.7,0.2,1.3,0.5,1.8
+ l0,0c0,0,0,0,0,0c0,0,0,0,0,0l0,0c0.2,0.4,0.6,0.6,1,0.9c-0.2-0.2-0.3-0.4-0.3-0.7C1.2,65.1,1.3,64.9,1.6,64.7z"/>
+ </g>
+</g>
+</svg>
diff --git a/servers/demo/templates/activate.html b/servers/demo/templates/activate.html
new file mode 100644
index 0000000..b19dfd2
--- /dev/null
+++ b/servers/demo/templates/activate.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+{% block extra-css %}
+ <style>
+ .button, button, input[type="submit"], input[type="reset"], input[type="button"] { font-size: 15px;}
+ </style>
+{% end %}
+
+{% block content %}
+ <h1>Identity activation</h1>
+
+ {% if not isValid %}
+ <p class="center">Cannot validate your identity! Reason: <label class="error">{{errorMessage}}</label></p>
+ {% elif activated %}
+ <div class="one column center">
+ <p>Your identity has been activated</p>
+ <p>You can now close this window and go back to complete your sign in procedure</p>
+ </div>
+ {% else %}
+ <p class="center">Please confirm your Milagro MFA activation:</p>
+ <!--action box start-->
+ <div class="one column center grey marBot20 marTop20">
+ <p>Email address: <label> {{userid}}</label></p>
+ <p>Requested on: <label id="issued">{{humanIssued}}</label></p>
+ <p>From: <label>{{deviceName}}</label></p>
+ <form method="POST">
+ <button type="submit">Confirm and activate</button>
+ <button onclick="window.location = '/'; return false;">Cancel activation</button>
+ </form>
+ </div>
+ <!--action box end-->
+ {% end %}
+ <div class="clear"></div>
+{% end %}
diff --git a/servers/demo/templates/activation_email.html b/servers/demo/templates/activation_email.html
new file mode 100644
index 0000000..83730e9
--- /dev/null
+++ b/servers/demo/templates/activation_email.html
@@ -0,0 +1,11 @@
+<html>
+<head></head>
+<body>
+<p><b>Milagro MFA Platform</b></p>
+<p>Your identity is now ready to activate:</p>
+<p><a href="{{ validationURL }}" target="_blank">Click this activation link and follow the instructions</a></p>
+<p>Regards,<br/>
+The Milagro MFA Team</p>
+</body>
+</html>
+
diff --git a/servers/demo/templates/activation_email.txt b/servers/demo/templates/activation_email.txt
new file mode 100644
index 0000000..0a6cdd4
--- /dev/null
+++ b/servers/demo/templates/activation_email.txt
@@ -0,0 +1,7 @@
+Milagro MFA Platform.
+Your identity is now ready to activate:
+Click this activation link and follow the instructions:
+{{ validationURL }}
+
+Regards,
+The Milagro MFA Team
diff --git a/servers/demo/templates/base.html b/servers/demo/templates/base.html
new file mode 100644
index 0000000..9801a4d
--- /dev/null
+++ b/servers/demo/templates/base.html
@@ -0,0 +1,52 @@
+{% import time %}
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <title>Milagro MFA demo</title>
+
+ <link href="{{ static_url("css/styles.css") }}" rel="stylesheet" type="text/css" />
+
+ <link rel="shortcut icon" href="{{ static_url("images/favicon.ico") }}">
+ <link rel="stylesheet" type="text/css" href="//fonts.googleapis.com/css?family=Open+Sans" />
+
+ {% block extra-javascript %}
+ {% end %}
+
+ {% block extra-css %}
+ {% end %}
+ </head>
+
+ <body>
+ <div id="header">
+ <div class="container">
+ <a href="http://milagro.incubator.apache.org/" target="_blank" class="logo1"><img src="{{ static_url("images/milagro-logo.svg") }}" alt="Milagro Logo" width="179" height="57" title="Milagro Logo" style="border-style: none"></a>
+ </div>
+ <div class="clear"></div>
+ </div>
+
+ {% try %}
+ {% if user %}
+ <div id="loggedInHolder"><div class="loggedInStatus">You are logged in as: {{ user }} | <a href="/logout"> Log Out </a></div></div>
+ {% end %}
+ {% except %}
+ {% end %}
+
+ <div id="content">
+ <div class="container">
+ <div class="nav">
+ <ul>
+ <li><a href="http://milagro.incubator.apache.org/" target="_blank">Milagro Web Site</a></li>
+ <li><a href="http://www.milagro.io/" target="_blank">Milagro Documentation</a></li>
+ <li><span class="support">Technical support: <a href="mailto:dev-subscribe@milagro.incubator.apache.org">Milagro mailing list</a></span></li>
+ </ul>
+ <div class="clear"></div>
+ </div>
+ <div class="content">
+ {% block content %}
+ {% end %}
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/servers/demo/templates/index.html b/servers/demo/templates/index.html
new file mode 100644
index 0000000..26b2b6e
--- /dev/null
+++ b/servers/demo/templates/index.html
@@ -0,0 +1,110 @@
+{% extends "base.html" %}
+{% block extra-javascript %}
+ <script type="text/javascript" src="{{ mpinJSURL }}?r={{int(time.time()*100)}}"></script>
+
+ <script type="text/javascript">
+ new mpin({
+ targetElement: "pinHolder",
+
+ clientSettingsURL: "{{ clientSettingsURL }}",
+ mobileAppFullURL: "{{ mobileAppFullURL }}",
+
+ successLoginURL: "/protected",
+
+ {% if mobileUseNative %}
+ mobileConfigURL: "{{ mobileConfigURL }}",
+ mobileNativeApp: true,
+ {% else %}
+ mobileNativeApp: false,
+ {% end %}
+
+ onSuccessSetup: function(authData, onSuccess) {
+ console.log("Setup PIN successful")
+ console.log(authData)
+ onSuccess()
+ },
+
+ onSuccessLogin: function(authData) {
+ window.location = "/protected"
+ },
+
+ onReactivate: function(userId){
+ window.location = "/new?userId=" + userId;
+ },
+
+ onUnsupportedBrowser: function(){
+ window.location = "http://www.miracl.com/browser-compatibility-page"
+ },
+
+ onVerifySuccess: function(data){
+ },
+ });
+
+ function createAjax(){
+ if (typeof XMLHttpRequest != "undefined")
+ {
+ return new XMLHttpRequest();
+ }
+ else if (window.ActiveXObject)
+ {
+ var aVersions = ["MSXML2.XMLHttp.5.0","MSXML2.XMLHttp.4.0","MSXML2.XMLHttp.3.0","MSXML2.XMLHttp","Microsoft.XMLHttp"];
+
+ for (var i = 0; i < aVersions.length; i++)
+ {
+ try
+ {
+ var oXmlHttp = new ActiveXObject(aVersions[i]);
+ return oXmlHttp;
+ }
+
+ catch(oError)
+ {
+ throw new Error("XMLHttp object could be created.");
+ }
+ }
+ }
+ throw new Error("XMLHttp object could be created.");
+ }
+
+ {% if user %}
+ (function waitLogout() {
+ xhr = createAjax();
+
+ xhr.onreadystatechange=function(evtXHR) {
+ if (xhr.readyState == 4)
+ {
+ if (xhr.status == 200)
+ {
+ window.location.reload();
+ }
+ }
+ };
+
+ xhr.open("GET", "{{ logoutWaitURL }}", true);
+ xhr.timeout = 30000;
+ xhr.ontimeout = function() {
+ waitLogout()
+ }
+ xhr.setRequestHeader("Content-Type", "application/json");
+ xhr.send()
+ })()
+ {% end %}
+ </script>
+{% end %}
+
+{% block content %}
+ {% if flash == "protected" %}
+ <div class="center" style="margin-top:10px;"><b class="dkRed" style="text-align:center">You are not authenticated! Please complete the following steps</b></div>
+ {% end %}
+ {% if flash == "forced_logout" %}
+ <div class="center" style="margin-top:10px;"><b class="dkRed" style="text-align:center">You have been logged out!</b></div>
+ {% end %}
+
+ <h1>Welcome to the Milagro MFA System Demo</h1>
+ <div class="one column center">
+ <div id="pinHolder" style="margin:auto; width:260px;">
+ Loading PinPad...
+ </div>
+ </div>
+ <div class="clear"></div>
+{% end %}
diff --git a/servers/demo/templates/protected.html b/servers/demo/templates/protected.html
new file mode 100644
index 0000000..73623c9
--- /dev/null
+++ b/servers/demo/templates/protected.html
@@ -0,0 +1,73 @@
+{% extends "base.html" %}
+{% block extra-javascript %}
+ <script type="text/javascript">
+ function createAjax()
+ {
+ if (typeof XMLHttpRequest != "undefined")
+ {
+ return new XMLHttpRequest();
+ }
+ else if (window.ActiveXObject)
+ {
+ var aVersions = ["MSXML2.XMLHttp.5.0","MSXML2.XMLHttp.4.0","MSXML2.XMLHttp.3.0","MSXML2.XMLHttp","Microsoft.XMLHttp"];
+
+ for (var i = 0; i < aVersions.length; i++)
+ {
+ try
+ {
+ var oXmlHttp = new ActiveXObject(aVersions[i]);
+ return oXmlHttp;
+ }
+ catch(oError)
+ {
+ throw new Error("XMLHttp object could be created.");
+ }
+ }
+ }
+ throw new Error("XMLHttp object could be created.");
+ }
+
+ {% if user %}
+ (function waitLogout(){
+ xhr = createAjax();
+
+ xhr.onreadystatechange=function(evtXHR) {
+ if (xhr.readyState == 4)
+ {
+ if (xhr.status == 200)
+ {
+ window.location = "/";
+ }
+ }
+ };
+
+ xhr.open("GET", "{{ logoutWaitURL }}", true);
+ xhr.timeout = 30000;
+ xhr.ontimeout = function() {
+ waitLogout()
+ }
+ xhr.setRequestHeader("Content-Type", "application/json");
+ xhr.send()
+ })()
+ {% end %}
+ </script>
+{% end %}
+
+{% block content %}
+ {% if welcome %}
+ <h1>{{ user }}, you are now logged in!</h1>
+ {% else %}
+ <h1>{{ user }}</h1>
+ <section class="center"><p>You see this page because you are logged in. <a href="/logout">Log out</a></p></section>
+ {% end %}
+
+ <section>
+ <div class="page-header section-header">
+ <h1>Usernames and Passwords are history</h1>
+ <div class="clear"></div>
+ <p class="secondary-header"><span class="section-subheader" id="hs_cos_wrapper_subheader">Security has evolved; the Future of Strong Authentication is here.</span></p>
+ <div class="clear"></div>
+ </div>
+ </section>
+ <div class="clear"></div>
+{% end %}
diff --git a/servers/dta/__init__.py b/servers/dta/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/servers/dta/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/servers/dta/config_default.py b/servers/dta/config_default.py
new file mode 100644
index 0000000..f7ff160
--- /dev/null
+++ b/servers/dta/config_default.py
@@ -0,0 +1,65 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import unicode_literals
+
+"""HTTP server settings"""
+address = "127.0.0.1"
+port = 8001
+
+"""Time synchronization
+
+To be able to perform time based verification, by default D-TA syncs its time
+with MIRACL servers. If you set it to False, you should still sync the server
+using an accurate NTP time server!
+"""
+# syncTime = False
+
+"""The location of your keys file (relative to mpin-backend/servers/dta)."""
+credentialsFile = '%CREDENTIALSFILE%'
+
+"""Entropy sources
+
+D-TA supports multiple ways to gather entropy random, urandom, certivox or
+combination of those.
+"""
+# EntropySources = 'dev_urandom:100' # Default
+# EntropySources = 'certivox:100'
+# EntropySources = 'dev_urandom:60,certivox:40'
+
+"""Backup master secret
+
+D-TA supports storing the master secret in a file rather than generating it every
+time on startup. It is enabled by default, set to False to disable. Master secret
+will be encrypted by default unless disabled by settingencrypt_master_secret to
+False. Master secret will be encoded with passphrase and salt to be provided
+- salt in the config file
+- passphrase - supplied on startup or in the config (not encouraged)
+
+Passphrase can be changed by running the service with changePassphrase option.
+
+To change the location of the backup file change backup_file option (relative to
+mpin-backend/servers/dta).
+"""
+# backup = False
+backup_file = '%BACKUP_FILE%'
+# encrypt_master_secret = False
+passphrase = '%PASSPHRASE%'
+salt = '%SALT%'
+
+"""Debug options"""
+# logLevel = "INFO"
diff --git a/servers/dta/dta.py b/servers/dta/dta.py
new file mode 100755
index 0000000..8a42654
--- /dev/null
+++ b/servers/dta/dta.py
@@ -0,0 +1,678 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import getpass
+import hashlib
+import hmac
+import os
+import sys
+
+import tornado.autoreload
+import tornado.escape
+import tornado.gen
+import tornado.httpclient
+import tornado.httpserver
+import tornado.ioloop
+import tornado.options
+import tornado.web
+from tornado.log import app_log as log
+from tornado.options import define, options
+from tornado.web import HTTPError
+
+from mpin_utils.common import (
+ detectProxy,
+ getLogLevel,
+ Keys,
+ Seed,
+ Time,
+ verifySignature,
+)
+from mpin_utils import secrets
+
+
+if os.name == "posix":
+ from mpDaemon import Daemon
+elif os.name == "nt":
+ from mpWinService import Service as Daemon
+else:
+ raise Exception("Unsupported platform: {0}".format(os.name))
+
+BASE_DIR = os.path.dirname(__file__)
+CONFIG_FILE = os.path.join(BASE_DIR, "config.py")
+DEFAULT_BACKUP_FILE = os.path.join(BASE_DIR, "backup.json")
+
+
+# OPTIONS
+
+# general options
+define("configFile", default=os.path.join(BASE_DIR, "config.py"), type=unicode)
+define("address", default="127.0.0.1", type=unicode)
+define("port", default=8001, type=int)
+
+# debugging options
+define("autoReload", default=False, type=bool)
+define("logLevel", default="ERROR", type=unicode)
+
+# time synchronization options
+define("timePeriod", default=86400000, type=int)
+define("syncTime", default=True, type=bool)
+
+# security options
+define("credentialsFile", default=os.path.join(BASE_DIR, "credentials.json"), type=unicode)
+define("EntropySources", default="dev_urandom:100", type=unicode)
+
+# backup master secret options
+define("backup", default=True, type=bool)
+define("backup_file", default=DEFAULT_BACKUP_FILE, type=unicode)
+define("encrypt_master_secret", default=True, type=bool)
+define("passphrase", type=unicode)
+define("salt", type=unicode)
+
+
+# BASE HANDLERS
+class BaseHandler(tornado.web.RequestHandler):
+ def set_default_headers(self):
+ self.set_header("Access-Control-Allow-Origin", "*")
+ self.set_header("Access-Control-Allow-Credentials", "true")
+ self.set_header("Access-Control-Allow-Methods", "GET, OPTIONS")
+ self.set_header("Access-Control-Allow-Headers", "Content-Type, Depth, User-Agent, X-File-Size, X-Requested-With, X-Requested-By, If-Modified-Since, X-File-Name, Cache-Control")
+
+ def write_error(self, status_code, **kwargs):
+ self.set_status(status_code, reason=self._reason.upper())
+ self.content_type = 'application/json'
+ self.write({'service_name': 'D-TA server', 'message': self._reason.upper()})
+
+ def options(self):
+ self.set_status(200, reason="OK")
+ self.content_type = 'application/json'
+ self.write({'service_name': 'D-TA server', 'message': "options request"})
+ self.finish()
+ return
+
+ def finish(self, *args, **kwargs):
+ if self._status_code == 401:
+ self.set_header("WWW-Authenticate", "Authenticate")
+ super(BaseHandler, self).finish(*args, **kwargs)
+
+
+# HANDLERS
+class ServerSecretHandler(BaseHandler):
+ """
+ .. apiTextStart
+
+ *Description*
+
+ Retrieves the M-Pin server secret
+
+ *URL structure*
+
+ ``/serverSecret?app_id=<app_id>&expires=<UTC Timestamp>&signature=<signature>``
+
+ *HTTP Request Method*
+
+ GET
+
+ *Parameters*
+
+ - app_id: <identity of the Application>
+
+ - expires: <time at which request expires>
+
+ - signature: <signature>
+
+ *Signature*
+
+ The signature is generated for this message
+
+ message = <serverSecret><app_id><expires>
+
+ *Returns*
+
+ Calculates the MPIN Server secret which is returned in this JSON object::
+ n
+ JSON response.
+ {
+ "message" : "OK",
+ "serverSecret" : "<serverSecret>"
+ }
+
+ *Status-Codes and Response-Phrases*
+
+ ::
+
+ Status-Code Response-Phrase
+
+ 200 OK
+ 401 Invalid signature
+ 403 Missing argument [value]
+ 408 Request expired
+ 500 M-Pin Server Secret Generation
+
+ .. apiTextEnd
+
+ """
+ def get(self):
+ # Remote request information
+ if 'User-Agent' in self.request.headers.keys():
+ UA = self.request.headers['User-Agent']
+ else:
+ UA = 'unknown'
+ request_info = '%s %s %s %s ' % (self.request.method, self.request.path, self.request.remote_ip, UA)
+
+ # Get arguments
+ try:
+ app_id = str(self.get_argument('app_id'))
+ expires = self.get_argument('expires')
+ signature = self.get_argument('signature')
+ except tornado.web.MissingArgumentError as ex:
+ reason = ex.log_message
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(403, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'message': reason})
+ self.finish()
+ return
+ request_info = request_info + app_id
+
+ # Get path used for signature
+ path = self.request.path
+ path = path.replace("/", "")
+
+ # Check signature is valid and that timestamp has not expired
+ M = str("%s%s%s" % (path, Keys.app_id, expires))
+ valid, reason, code = verifySignature(M, signature, Keys.app_key, expires)
+ if not valid:
+ return_data = {
+ 'code': code,
+ 'message': reason
+ }
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(status_code=code, reason=reason)
+ self.content_type = 'application/json'
+ self.write(return_data)
+ self.finish()
+ return
+
+ try:
+ server_secret_hex = self.application.master_secret.get_server_secret()
+ except secrets.SecretsError as e:
+ log.error('M-Pin Server Secret Generation Failed: {0}. Request info: {1}'.format(e, request_info))
+ return_data = {
+ 'errorCode': e.message,
+ 'reason': 'M-Pin Server Secret Generation Failed',
+ }
+ self.set_status(500, reason=reason)
+ self.content_type = 'application/json'
+ self.write(return_data)
+ self.finish()
+ return
+
+ # Hash server secret share
+ server_secret = server_secret_hex.decode("hex")
+ hash_server_secret_hex = hashlib.sha256(server_secret).hexdigest()
+ log.info("%s hash_server_secret_hex: %s" % (request_info, hash_server_secret_hex))
+
+ # Returned data
+ reason = "OK"
+ self.set_status(200, reason=reason)
+ self.content_type = 'application/json'
+ return_data = {
+ 'serverSecret': server_secret_hex,
+ 'startTime': Time.DateTimeToISO(self.application.master_secret.start_time),
+ 'message': reason
+ }
+ self.write(return_data)
+ log.debug("%s %s" % (request_info, return_data))
+ self.finish()
+ return
+
+
+class ClientSecretHandler(BaseHandler):
+ """
+ .. apiTextStart
+
+ *Description*
+
+ Retrieves the M-Pin client secret
+
+ *URL structure*
+
+ ``/clientSecret?app_id=<app_id>&expires=<UTC Timestamp>&hash_mpin_id=<hash_mpin_id>&signature=<signature>&mobile=<0||1>``
+
+ *HTTP Request Method*
+
+ GET
+
+ *Parameters*
+
+
+ - app_id: identity of the Application
+
+ - hash_mpin_id: hex encoded hash of the M-Pin identity for which client secret is requested
+
+ - expires: time at which request expires
+
+ - signature: signature
+
+ - mobile: 1 means mobile request || 0 means desktop request
+
+ *Signature*
+
+ The signature is generated for this message using a hmac
+
+ message = <clientSecret><app_id><hash_mpin_id><expires>
+
+ *Returns*
+
+ Calculates the MPIN Client secret which is returned in this JSON object::
+
+ JSON response.
+ {
+ "message" : "OK",
+ "clientSecret" : "<clientSecret>"
+ }
+
+ *Status-Codes and Response-Phrases*
+
+ ::
+
+ Status-Code Response-Phrase
+
+ 200 OK
+ 401 Invalid signature
+ 403 Missing argument [value]
+ 403 Invalid data received. Hex object could be decoded
+ 403 Invalid data received. hash_mpin_id null
+ 403 Invalid data received. hash_mpin_id should be 64 bytes
+ 408 Request expired
+ 500 M-Pin Client Secret Generation Failed
+
+ .. apiTextEnd
+
+ """
+ def get(self):
+ # Remote request information
+ if 'User-Agent' in self.request.headers.keys():
+ UA = self.request.headers['User-Agent']
+ else:
+ UA = 'unknown'
+ request_info = '%s %s %s %s ' % (self.request.method, self.request.path, self.request.remote_ip, UA)
+
+ # Get arguments
+ try:
+ app_id = str(self.get_argument('app_id'))
+ expires = self.get_argument('expires')
+ signature = self.get_argument('signature')
+ hash_mpin_id_hex = self.get_argument('hash_mpin_id')
+ hash_mpin_id = hash_mpin_id_hex.decode("hex")
+ hash_user_id = self.get_argument('hash_user_id')
+ except tornado.web.MissingArgumentError as ex:
+ reason = ex.log_message
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(403, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'message': reason})
+ self.finish()
+ return
+ except TypeError as ex:
+ reason = "Invalid data received. Hex object could be decoded"
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(403, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'message': reason})
+ self.finish()
+ return
+ if len(hash_mpin_id_hex) != 64:
+ reason = "Invalid data received. hash_mpin_id should be 64 bytes"
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(403, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'message': reason})
+ self.finish()
+ return
+ request_info = request_info + app_id + " " + hash_mpin_id_hex
+
+ # Get path used for signature
+ path = self.request.path
+ path = path.replace("/", "")
+
+ # Check signature is valid and that timestamp has not expired
+ M = str("%s%s%s%s%s" % (path, Keys.app_id, hash_mpin_id_hex, hash_user_id, expires))
+ valid, reason, code = verifySignature(M, signature, Keys.app_key, expires)
+ if not valid:
+ return_data = {
+ 'code': code,
+ 'message': reason
+ }
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(status_code=code, reason=reason)
+ self.content_type = 'application/json'
+ self.write(return_data)
+ self.finish()
+ return
+
+ try:
+ client_secret_hex = self.application.master_secret.get_client_secret(hash_mpin_id)
+ except secrets.SecretsError as e:
+ return_data = {
+ 'errorCode': e.message,
+ 'message': 'M-Pin Client Secret Generation Failed'
+ }
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(500, reason=reason)
+ self.content_type = 'application/json'
+ self.write(return_data)
+ self.finish()
+ return
+ # Hash client secret share
+ client_secret = client_secret_hex.decode("hex")
+ hash_client_secret_hex = hashlib.sha256(client_secret).hexdigest()
+ log.info("%s hash_client_secret_hex: %s" % (request_info, hash_client_secret_hex))
+
+ reason = "OK"
+ self.set_status(200, reason=reason)
+ self.content_type = 'application/json'
+ return_data = {
+ 'clientSecret': client_secret_hex,
+ 'message': reason
+ }
+ self.write(return_data)
+ log.debug("%s %s" % (request_info, return_data))
+ self.finish()
+ return
+
+
+class TimePermitsHandler(BaseHandler):
+
+ def get_hash_mpin_id_hex(self):
+ try:
+ hash_mpin_id_hex = self.get_argument('hash_mpin_id')
+ except tornado.web.MissingArgumentError as e:
+ log.debug(e)
+ raise HTTPError(403, e.log_message)
+
+ if len(hash_mpin_id_hex) != 64:
+ reason = "Invalid data received. hash_mpin_id should be 64 bytes"
+ log.debug(reason)
+ raise HTTPError(403, reason)
+
+ return hash_mpin_id_hex
+
+ def get_hash_mpin_id(self, hash_mpin_id_hex):
+ hash_mpin_id_hex = self.get_hash_mpin_id_hex()
+ try:
+ return hash_mpin_id_hex.decode("hex")
+ except TypeError:
+ reason = "Invalid data received. Hex object could be decoded"
+ log.debug(reason)
+ raise HTTPError(403, reason)
+
+ def get_signature(self):
+ try:
+ return self.get_argument('signature')
+ except tornado.web.MissingArgumentError as e:
+ raise HTTPError(403, e.log_message)
+
+ def get_count(self):
+ try:
+ count = self.get_argument('count')
+ except tornado.web.MissingArgumentError as e:
+ raise HTTPError(403, e.log_message)
+
+ try:
+ count = int(count)
+ except ValueError:
+ raise HTTPError(403, 'Count invalid format integer')
+
+ return count
+
+ def verify_signature(self, signature, hash_mpin_id_hex):
+ hmacExpected = hmac.new(Keys.app_key, hash_mpin_id_hex.encode('utf-8'), hashlib.sha256).hexdigest()
+ hmac1 = hmac.new(Keys.app_key, signature, hashlib.sha256).hexdigest()
+ hmac2 = hmac.new(Keys.app_key, hmacExpected, hashlib.sha256).hexdigest()
+ return hmac1 == hmac2
+
+ def get_timepermits(self, hash_mpin_id, count):
+ try:
+ time_permits = self.application.master_secret.get_time_permits(
+ hash_mpin_id, count=count)
+ except secrets.SecretsError as e:
+ raise HTTPError(500, e.message)
+
+ return time_permits
+
+ def get(self):
+ hash_mpin_id_hex = self.get_hash_mpin_id_hex()
+ hash_mpin_id = self.get_hash_mpin_id(hash_mpin_id_hex)
+ signature = self.get_signature()
+ count = self.get_count()
+
+ if not self.verify_signature(signature, hash_mpin_id_hex):
+ reason = "Invalid signature"
+ log.debug(reason)
+ raise HTTPError(401, reason)
+
+ self.finish({
+ 'timePermits': self.get_timepermits(hash_mpin_id, count),
+ 'message': 'OK'
+ })
+
+
+class TimePermitHandler(TimePermitsHandler):
+
+ """Kept for backwards compatibility."""
+
+ def get_timepermit(self, hash_mpin_id):
+ return self.get_timepermits(hash_mpin_id, 1).values()[0]
+
+ def get(self):
+ hash_mpin_id_hex = self.get_hash_mpin_id_hex()
+ hash_mpin_id = self.get_hash_mpin_id(hash_mpin_id_hex)
+ signature = self.get_signature()
+
+ if not self.verify_signature(signature, hash_mpin_id_hex):
+ reason = "Invalid signature"
+ log.debug(reason)
+ raise HTTPError(401, reason)
+
+ self.finish({
+ 'timePermit': self.get_timepermit(hash_mpin_id),
+ 'message': 'OK'
+ })
+
+
+class StatusHandler(BaseHandler):
+ """
+ .. apiTextStart
+
+ *Description*
+
+ Retrieves status of the D-TA Proxy.
+
+ *URL structure*
+
+ ``/status``
+
+ *HTTP Request Method*
+
+ GET
+
+ *Returns*
+
+ JSON response::
+
+ {
+ 'message' : 'OK',
+ 'startTime': <DateTime>
+ 'service_name': 'D-TA server'
+ }
+
+ *Status-Codes and Response-Phrases*
+
+ ::
+
+ Status-Code Response-Phrase
+
+ 200 OK
+
+ .. apiTextEnd
+ """
+
+ def get(self):
+ reason = "OK"
+ self.set_status(200, reason=reason)
+ start_time_str = Time.DateTimeToISO(self.application.master_secret.start_time),
+ self.write({'startTime': start_time_str, 'service_name': 'D-TA server', 'message': reason})
+ return
+
+
+class DefaultHandler(BaseHandler):
+ def get(self, input):
+ reason = "NOT FOUND"
+ self.set_status(404, reason=reason)
+ self.write({'service_name': 'D-TA server', 'message': reason})
+ return
+
+ def post(self, input):
+ reason = "URI NOT FOUND"
+ self.set_status(404, reason=reason)
+ self.write({'service_name': 'D-TA server', 'message': reason})
+ return
+
+ def put(self, input):
+ reason = "URI NOT FOUND"
+ self.set_status(404, reason=reason)
+ self.write({'service_name': 'D-TA server', 'message': reason})
+ return
+
+ def delete(self, input):
+ reason = "URI NOT FOUND"
+ self.set_status(404, reason=reason)
+ self.write({'service_name': 'D-TA server', 'message': reason})
+ return
+
+
+# MAIN
+class Application(tornado.web.Application):
+ def __init__(self):
+ handlers = [
+ (r"/clientSecret", ClientSecretHandler),
+ (r"/serverSecret", ServerSecretHandler),
+ (r"/timePermit", TimePermitHandler),
+ (r"/timePermits", TimePermitsHandler),
+ (r"/status", StatusHandler),
+ (r"/(.*)", DefaultHandler),
+ ]
+ settings = dict(
+ xsrf_cookies=False
+ )
+ super(Application, self).__init__(handlers, **settings)
+
+ Seed.getSeed(options.EntropySources) # Get seed value for random number generator
+ self.master_secret = secrets.MasterSecret(
+ passphrase=options.passphrase,
+ salt=options.salt,
+ seed=Seed.seedValue,
+ backup_file=options.backup_file,
+ encrypt_master_secret=options.encrypt_master_secret,
+ time=Time.syncedNow())
+
+
+def main():
+ options.parse_command_line()
+
+ if os.path.exists(options.configFile):
+ try:
+ options.parse_config_file(options.configFile)
+ options.parse_command_line()
+ except Exception, E:
+ print("Invalid config file {0}".format(options.configFile))
+ print(E)
+ sys.exit(1)
+
+ # Set Log level
+ log.setLevel(getLogLevel(options.logLevel))
+
+ detectProxy()
+
+ # Load the credentials from file
+ log.info("Loading credentials")
+ try:
+ credentialsFile = options.credentialsFile
+ Keys.loadFromFile(credentialsFile)
+ except Exception as E:
+ log.error("Error opening the credentials file: {0}".format(credentialsFile))
+ log.error(E)
+ sys.exit(1)
+
+ # TMP fix for 'ValueError: I/O operation on closed epoll fd'
+ # Fixed in Tornado 4.2
+ tornado.ioloop.IOLoop.instance()
+
+ # Sync time to CertiVox time server
+ if options.syncTime:
+ Time.getTime(wait=True)
+
+ if options.backup and options.encrypt_master_secret and not options.passphrase:
+ options.passphrase = getpass.getpass("Please enter passphrase:")
+
+ http_server = Application()
+ http_server.listen(options.port, options.address, xheaders=True)
+ io_loop = tornado.ioloop.IOLoop.instance()
+
+ if options.autoReload:
+ log.debug("Starting autoreloader")
+ tornado.autoreload.watch(CONFIG_FILE)
+ tornado.autoreload.start(io_loop)
+
+ if options.syncTime and (options.timePeriod > 0):
+ scheduler = tornado.ioloop.PeriodicCallback(Time.getTime, options.timePeriod, io_loop=io_loop)
+ scheduler.start()
+
+ log.info("Server started. Listening on {0}:{1}".format(options.address, options.port))
+ io_loop.start()
+
+
+class ServiceDaemon(Daemon):
+ def run(self):
+ main()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1].lower() in ("start", "stop"):
+ action = sys.argv.pop(1)
+ logFile = os.path.join(BASE_DIR, "dta.log")
+ pidFile = os.path.join(BASE_DIR, "dta.pid")
+
+ daemon = ServiceDaemon(pidfile=pidFile, stdout=logFile, stderr=logFile)
+ if action == "start":
+ log.info("Starting as daemon. Log file: {0}".format(logFile))
+ daemon.start()
+ elif action == "stop":
+ log.info("Stopping daemon...")
+ daemon.stop()
+ sys.exit()
+ else:
+ try:
+ main()
+ except Exception as e:
+ log.error(e)
+ sys.exit(1)
diff --git a/servers/dta/dtaservice.py b/servers/dta/dtaservice.py
new file mode 100755
index 0000000..a13ebd4
--- /dev/null
+++ b/servers/dta/dtaservice.py
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import sys
+
+if (os.name != "nt"):
+ print "This file can be run on Windows."
+ sys.exit(1)
+
+import dta
+from mpWinService import installService
+
+
+installService(dta.ServiceDaemon, 'mpinDTA', 'M-Pin DTA Service')
diff --git a/servers/rps/__init__.py b/servers/rps/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/servers/rps/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/servers/rps/config_default.py b/servers/rps/config_default.py
new file mode 100644
index 0000000..915efee
--- /dev/null
+++ b/servers/rps/config_default.py
@@ -0,0 +1,133 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import unicode_literals
+
+"""HTTP server settings"""
+address = '127.0.0.1'
+port = 8011
+
+"""Set Access-Control-Allow-Origin header"""
+# allowOrigin = ['*']
+
+"""Time synchronization
+
+To be able to perform time based verification, by default RPS syncs its time
+with MIRACL servers. If you set it to False, you should still sync the server
+using an accurate NTP time server!
+"""
+# syncTime = False
+
+"""
+Dynamic options url
+
+Location to be queried for dynamically (runtime) changeable options.
+'None' mean dynamic options are disabled and it is default value.
+"""
+# dynamicOptionsURL = None # Default
+
+"""The location of your keys file (relative to mpin-backend/servers/dta)."""
+credentialsFile = '%CREDENTIALSFILE%'
+
+"""Entropy sources
+
+D-TA supports multiple ways to gather entropy random, urandom, certivox or
+combination of those.
+"""
+# EntropySources = 'dev_urandom:100' # Default
+# EntropySources = 'certivox:100'
+# EntropySources = 'dev_urandom:60,certivox:40'
+
+"""MIRACL server secret share acquisition
+
+- dta - get server secret from MIRACL dta automatically on start
+- credentials.json - get server secret from credentials.json (key: certivox_server_secret)
+- manual - service will prompt for it
+- the secret itself
+
+You can get your MIRACL server secret by:
+ ./scripts/getServerSecretShare.py credentials.json
+which will output your credentials json including certivox_server_secret.
+NOTE: Don't pipe it directly to the same file - you'll lose your original
+ credentials file.
+Alternatively you can copy only your certivox_server_secret value and supply it
+either manually or via config.py setting the certivoxServerSecret to the
+corresponding value.
+"""
+# certivoxServerSecret = 'dta' # Default
+
+"""Local DTA address."""
+DTALocalURL = 'http://127.0.0.1:8001'
+
+"""Access number options
+
+- enable access number
+- accessNumberExpireSeconds - The default time client will show the access number
+- accessNumberExtendValiditySeconds - Validity of the access number (on top of accessNumberExpireSeconds)
+- accessNumberUseCheckSum - Should access number have checksum
+"""
+# requestOTP = True
+# accessNumberExpireSeconds = 60 # Default
+# accessNumberExtendValiditySeconds = 5 # Default
+# accessNumberUseCheckSum = True # Default
+
+"""Authentication options
+
+- waitForLoginResult -For the mobile flow. Wait the browser login before showing the Done/Logout button.
+"""
+waitForLoginResult = True
+# VerifyUserExpireSeconds = 3600 # Default
+# maxInvalidLoginAttempts = 3 # Default
+# cacheTimePermits = True #Default
+
+"""RPA options
+
+- RPAPermitUserURL - RPA Revocation endpoint
+- RegisterForwardUserHeaders - Coma separated list of headers
+ - '' - do not forward headers
+ - * - forward all headers
+- LogoutURL - RPA Logout url. For logout using the mobile client.
+"""
+RPAVerifyUserURL = 'http://127.0.0.1:8005/mpinVerify'
+# RPAPermitUserURL = 'http://127.0.0.1:8005/mpinPermitUser'
+RPAAuthenticateUserURL = '/mpinAuthenticate'
+RegisterForwardUserHeaders = ''
+LogoutURL = '/logout'
+
+"""PIN pad client options"""
+# rpsBaseURL = ''
+# rpsPrefix = 'rps' # Default
+# setDeviceName = True
+
+"""Key value storage options"""
+storage = 'memory'
+
+# storage = 'redis'
+# redisHost = '127.0.0.1' # Default
+# redisPort = 6379 # Default
+# redisDB = 0 # Default
+# redisPassword = None # Default
+# redisPrefix = 'mpin' # Default
+
+# storage = 'json'
+# fileStorageLocation = './mpin_rps_storage.json'
+
+"""Debug options"""
+# logLevel = "INFO"
+
+"""Use NFC flag for mobile clients"""
+useNFC = False
diff --git a/servers/rps/rps.py b/servers/rps/rps.py
new file mode 100755
index 0000000..4dd9c6f
--- /dev/null
+++ b/servers/rps/rps.py
@@ -0,0 +1,1630 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import division, absolute_import, print_function, unicode_literals
+
+import datetime
+import hashlib
+import json
+import os
+import random
+import sys
+import time
+import urllib
+from urlparse import urlparse
+
+import tornado.autoreload
+import tornado.gen
+import tornado.httpclient
+import tornado.httpserver
+import tornado.web
+import tornado.websocket
+from tornado.httputil import url_concat
+from tornado.log import app_log as log
+from tornado.options import define, options
+from tornado.web import HTTPError
+
+from mpin_utils.common import (
+ detectProxy,
+ getLogLevel,
+ Keys,
+ Seed,
+ SIGNATURE_EXPIRES_OFFSET_SECONDS,
+ signMessage,
+ Time,
+)
+from mpin_utils import secrets
+from storage import get_storage_cls
+from dynamic_options import (
+ generate_dynamic_options,
+ process_dynamic_options,
+)
+
+if os.name == "posix":
+ from mpDaemon import Daemon
+elif os.name == "nt":
+ from mpWinService import Service as Daemon
+else:
+ raise Exception("Unsupported platform: {0}".format(os.name))
+
+
+VERSION = '0.3'
+BASE_DIR = os.path.dirname(__file__)
+CONFIG_FILE = os.path.join(BASE_DIR, "config.py")
+MOBILE_LOGIN_AUTHENTICATION_TIMEOUT_SECONDS = 10
+
+PASS1_EXPIRES_TIME = 15
+PERMITS_MIN, PERMITS_MAX = 7, 13
+
+
+# OPTIONS
+
+# general options
+define("configFile", default=os.path.join(BASE_DIR, "config.py"), type=unicode)
+define("address", default="127.0.0.1", type=unicode)
+define("port", default=8011, type=int)
+define("allowOrigin", default="*")
+define("dynamicOptionsURL", default=None, type=unicode)
+
+# debugging options
+define("autoReload", default=False, type=bool)
+define("logLevel", default="ERROR", type=unicode)
+
+# time synchronization options
+define("timePeriod", default=86400000, type=int)
+define("syncTime", default=True, type=bool)
+
+# security options
+define("credentialsFile", default=os.path.join(BASE_DIR, "credentials.json"), type=unicode)
+define("EntropySources", default="certivox:100", type=unicode)
+define("seedValueLength", default=100, type=int)
+
+# customer DTA service discovery options
+define("DTALocalURL", default="", type=unicode)
+
+# access number options
+define("accessNumberExpireSeconds", default=60, type=int)
+define("accessNumberExtendValiditySeconds", default=5, type=int)
+define("accessNumberUseCheckSum", default=True, type=bool)
+
+# authentication options
+define("waitForLoginResult", default=False, type=bool)
+define("VerifyUserExpireSeconds", default=3600, type=int)
+define("maxInvalidLoginAttempts", default=3, type=int)
+define("cacheTimePermits", default=True, type=bool)
+
+# OTP options
+define("requestOTP", default=False, type=bool)
+define("OTTLength", default=16, type=int)
+
+# RPA options
+define("RPAVerifyUserURL", default="", type=unicode)
+define("RPAPermitUserURL", default="", type=unicode)
+define("RPAAuthenticateUserURL", default="", type=unicode)
+define("RegisterForwardUserHeaders", default="", type=unicode)
+define("LogoutURL", default="", type=unicode)
+
+# PIN pad client options
+define("rpsBaseURL", default="")
+define("rpsPrefix", default="rps")
+define("setDeviceName", default=False, type=bool)
+
+# mobile client config
+define("mobileUseNative", default=False, type=bool)
+define("mobileConfig", default=None, type=list)
+define("useNFC", default=False, type=bool)
+
+
+# Mapping between local names of dynamic options and names from json
+# in the form `remote_name`: `local_name`
+# Only options that have mapping are processed
+DYNAMIC_OPTION_MAPPING = {
+ 'time_synchronization': 'syncTime',
+ 'time_synchronization_period': 'timePeriod',
+ 'mobile_use_native': 'mobileUseNative',
+ 'mobile_client_config': 'mobileConfig',
+}
+
+
+# Dynamic options handlers
+def handle_time_synchronization_update(updated, application, initial):
+
+ log.debug("Handling time synchronization")
+ if not any(x in updated for x in ('syncTime', 'timePeriod')) and not initial:
+ log.debug("Nothing to do on time synchronization")
+ return
+
+ def _stop_scheduler():
+ try:
+ application.time_sync_scheduler.stop()
+ log.debug("Stopped time sync schduler")
+ except:
+ pass
+ if options.syncTime and (options.timePeriod > 0):
+ _stop_scheduler()
+ application.time_sync_scheduler \
+ = tornado.ioloop.PeriodicCallback(
+ Time.getTime,
+ options.timePeriod,
+ io_loop=application.io_loop)
+ application.time_sync_scheduler.start()
+ log.debug(
+ "Started time sync schduler with period {0}"
+ .format(options.timePeriod))
+ else:
+ _stop_scheduler()
+
+
+# Convenience variable - list of dynamic options update handlers
+DYNAMIC_OPTION_HANDLERS = [
+ handle_time_synchronization_update,
+]
+
+
+# UTILITIES
+def makeMPinID(userId, isMobile):
+ endUserData = {
+ "issued": str(Time.syncedNow()),
+ "userID": userId,
+ "mobile": int(isMobile or 0),
+ "salt": os.urandom(16).encode("hex")
+ }
+
+ mpin_id = json.dumps(endUserData)
+
+ return mpin_id.encode("hex")
+
+
+def verifyToken(token):
+ """A method for verifying the authentication token.
+
+ n.b. The message variable should not be returned in a deployed application
+ """
+ successCode = int(token["successCode"])
+ pinError = int(token["pinError"])
+
+ # Get current time and token expired time
+ expiresStr = token["expires"].replace(" ", "T").replace("Z", "")
+ expiresTime = datetime.datetime.strptime(expiresStr, '%Y-%m-%dT%H:%M:%S')
+ syncedTime = Time.syncedNow()
+
+ # Check if token has expired.
+ if syncedTime > expiresTime:
+ fail = 1
+ status = 401
+ reason = "Authentication Failed. Token Expired."
+
+ elif successCode != 0:
+ if pinError == 0: # No token.
+ fail = 1
+ status = 401
+ reason = "Authentication Failed. Invalid Token."
+ else:
+ # Entering wrong PIN.
+ fail = 1
+ status = 401
+ reason = "Authentication Failed. Invalid PIN."
+ else:
+ # Successful authentication
+ fail = 0
+ status = 200
+ reason = "OK"
+
+ return (fail, status, reason)
+
+
+# BASE HANDLERS
+class BaseHandler(tornado.web.RequestHandler):
+
+ def set_default_headers(self):
+ try:
+ log.debug("Origin Header %s" % self.request.headers['Origin'])
+ if self.request.headers['Origin'] in options.allowOrigin:
+ self.set_header("Access-Control-Allow-Origin", self.request.headers['Origin'])
+ elif "*" in options.allowOrigin:
+ self.set_header("Access-Control-Allow-Origin", "*")
+ except:
+ log.debug("Origin header not defined")
+ self.set_header("Access-Control-Allow-Credentials", "true")
+ self.set_header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS")
+ self.set_header("Access-Control-Allow-Headers", "Content-Type, Depth, User-Agent, X-File-Size, X-Requested-With, X-Requested-By, If-Modified-Since, X-File-Name, Cache-Control, WWW-Authenticate")
+
+ def write_error(self, status_code, **kwargs):
+ self.set_status(status_code, reason=self._reason)
+ self.content_type = 'application/json'
+ self.write({'version': VERSION, 'message': self._reason})
+
+ def options(self, *args, **kwargs):
+ self.set_status(200, reason="OK")
+ self.content_type = 'application/json'
+ self.write({'version': VERSION, 'message': "options request"})
+ self.finish()
+ return
+
+ def finish(self, *args, **kwargs):
+ if self._status_code == 401:
+ self.set_header("WWW-Authenticate", "Authenticate")
+ super(BaseHandler, self).finish(*args, **kwargs)
+
+ @property
+ def storage(self):
+ return self.application.storage
+
+
+class PrivateBaseHandler(BaseHandler):
+
+ def prepare(self):
+ # TODO: Check the remoteIP option
+ # allow connections from whitelisted IP's
+ # print self.request.remote_ip
+ # self.set_status(404)
+ # self.finish()
+ pass
+
+
+# PUBLIC HANDLERS
+class ClientSettingsHandler(BaseHandler):
+ def get(self):
+ self.set_header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
+ self.set_header("Pragma", "no-cache")
+ self.set_header("Expires", "Sat, 26 Jul 1997 05:00:00 GMT")
+
+ baseURL = "{0}/{1}".format(options.rpsBaseURL, options.rpsPrefix.strip("/"))
+ params = {
+ "certivoxURL": Keys.certivoxServer(),
+ "signatureURL": "{0}/signature".format(baseURL),
+ "registerURL": "{0}/user".format(baseURL),
+ "timePermitsURL": "{0}/timePermit".format(baseURL),
+ "timePermitsStorageURL": "{0}".format(Keys.timePermitsStorageURL),
+ "setupDoneURL": "{0}/setupDone".format(baseURL),
+ "mpinAuthServerURL": baseURL,
+ "authenticateURL": options.RPAAuthenticateUserURL,
+ "mobileAuthenticateURL": "{0}/authenticate".format(baseURL),
+ "setDeviceName": options.setDeviceName,
+ "accessNumberUseCheckSum": options.accessNumberUseCheckSum,
+
+ "appID": Keys.app_id,
+ "requestOTP": options.requestOTP,
+ "seedValue": secrets.generate_random_number(
+ self.application.server_secret.rng, options.seedValueLength),
+
+ "useWebSocket": False,
+
+ "accessNumberDigits": 7 if options.accessNumberUseCheckSum else 6,
+ "cSum": 1,
+ "useNFC": options.useNFC,
+ }
+
+ if not options.requestOTP:
+ params["accessNumberURL"] = "{0}/accessnumber".format(baseURL)
+ params["getAccessNumberURL"] = "{0}/getAccessNumber".format(baseURL)
+
+ self.write(params)
+ self.finish()
+
+
+class RPSUserHandler(BaseHandler):
+
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def put(self, mpinId):
+
+ try:
+ data = json.loads(self.request.body)
+ mobile = int(data.get("mobile", "0"))
+ userId = data.get("userId")
+ deviceName = data.get("deviceName", "")
+ oldRegOTT = data.get("regOTT")
+
+ if not userId:
+ log.error("Missing userId")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID USERID")
+ self.finish()
+ return
+
+ except ValueError:
+ log.error("Cannot decode body as JSON.")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+
+ if mpinId.strip("/"):
+ mpinId = mpinId.strip("/")
+ log.debug("Reactivation request for mpinId: {0}".format(mpinId))
+
+ updateItem = self.storage.find(stage="register", mpinId=mpinId)
+ if not updateItem:
+ mpinId = None
+ oldRegOTT = None
+ log.error("Missing or invalid mpinID. Will generate a new mpinID")
+
+ elif updateItem.regOTT != oldRegOTT:
+ log.error("Missing or invalid regOTT")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID REGOTT")
+ self.finish()
+ return
+
+ if not mpinId:
+ # Generate new mpinID
+ updateItem = None
+ mpinId = makeMPinID(userId, mobile)
+ log.debug("New mpinID generated for user {0}: {1}".format(userId, mpinId))
+
+ userData = data.get("userData")
+
+ # Verify user >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+
+ # Generate activateKey
+ regOTT = oldRegOTT or secrets.generate_ott(options.OTTLength, self.application.server_secret.rng, "hex")
+ activateKey = signMessage("{0}{1}".format(mpinId, regOTT), Keys.app_key)
+ nowTime = Time.syncedNow()
+ expireTime = nowTime + datetime.timedelta(seconds=options.VerifyUserExpireSeconds)
+
+ requestBody = json.dumps({
+ "userId": userId,
+ "mpinId": mpinId,
+ "mobile": mobile,
+ "activateKey": activateKey,
+ "expireTime": Time.DateTimeToISO(expireTime),
+ "resend": bool(updateItem),
+ "deviceName": deviceName,
+ "userData": userData or ""
+ })
+
+ if updateItem:
+ updateItem.delete()
+
+ client = tornado.httpclient.AsyncHTTPClient()
+
+ pr = urlparse(self.request.full_url())
+ base_url = "{0}://{1}".format(pr.scheme, pr.netloc)
+
+ headers = {
+ "RPS-BASE-URL": base_url
+ }
+
+ # Forward headers to the RPA
+ if options.RegisterForwardUserHeaders:
+ allHeaders = options.RegisterForwardUserHeaders == "*"
+ rHeaders = map(lambda x: x.strip().lower(), options.RegisterForwardUserHeaders.split(","))
+ for h in self.request.headers:
+ if allHeaders or (h.lower() in rHeaders):
+ headers[h] = self.request.headers[h]
+
+ RPAVerifyUserURL = options.RPAVerifyUserURL
+
+ if not RPAVerifyUserURL:
+ log.error("RPAVerifyUserURL option not set! Unable to make Verify request")
+ self.set_status(400, "RPAVerifyUserURL option not set.")
+ self.finish()
+ return
+
+ # Make the verify request to the RPA
+ response = yield tornado.gen.Task(client.fetch, RPAVerifyUserURL, method="POST", headers=headers, body=requestBody)
+
+ if response.error:
+ log.error("RPA verify request error: {0}. Code: {1}, Reason: {2}".format(response.error, response.code, response.reason))
+ error = response.code
+ if error >= 500:
+ error = 500
+
+ self.set_status(error)
+ self.finish()
+ return
+
+ forceActivate = False
+ if response.body:
+ try:
+ responseData = json.loads(response.body)
+ forceActivate = responseData.get("forceActivate", forceActivate)
+ except:
+ log.error("RPA verify request: Invalid JSON response: {0}".format(response.body))
+ self.set_status(500)
+ self.finish()
+ return
+
+ if forceActivate:
+ log.debug("RPA response: force_activate. Activating UserID: {0}".format(userId))
+
+ if forceActivate:
+ active = activateKey
+ else:
+ active = 0
+
+ log.debug("New regOTT generated: {0}. ForceActivate: {1}".format(regOTT, forceActivate))
+
+ self.storage.add(
+ expire_time=expireTime,
+ stage="register",
+ mpinId=mpinId,
+ regOTT=regOTT,
+ active=active
+ )
+
+ # Response to the client
+ responseData = {
+ "mpinId": mpinId,
+ "regOTT": regOTT,
+ "expireTime": expireTime.isoformat(),
+ "nowTime": nowTime.isoformat(),
+ "active": forceActivate
+ }
+
+ self.write(responseData)
+ self.finish()
+
+
+class RPSSignatureHandler(BaseHandler):
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def get(self, mpinId):
+
+ regOTT = self.get_argument("regOTT")
+
+ log.debug("ClientSecret request for mpinID: {0}".format(mpinId))
+
+ mpinData = json.loads(mpinId.decode("hex"))
+ mobile = mpinData.get("mobile", 0)
+
+ I = self.storage.find(stage="register", mpinId=mpinId)
+ if not I:
+ log.debug("MpinID {0} not found.".format(mpinId))
+ self.set_status(400, "M-Pin ID not found.")
+ self.finish()
+ return
+
+ # Verify regOTT
+ if I.regOTT != regOTT:
+ log.error("MpinID {0} regOTT does not match!".format(mpinId))
+ I.delete()
+ self.set_status(400, "M-Pin ID not found.")
+ self.finish()
+ return
+
+ # Verify Activated
+ if I.active != signMessage("{0}{1}".format(mpinId, regOTT), Keys.app_key):
+ log.debug("MpinID {0} is not activated!".format(mpinId))
+ self.set_status(401, "M-PinID not active.")
+ self.finish()
+ return
+
+ # Get hash of M-PIN ID
+ hash_mpin_id_hex = hashlib.sha256(mpinId.decode("hex")).hexdigest()
+
+ # Get hash of UserID ID
+ hash_user_id = hashlib.sha256('{user_id}{salt}'.format(
+ user_id=mpinData['userID'],
+ salt=hashlib.sha256(self.application.server_secret.server_secret).hexdigest(),
+ )).hexdigest()
+
+ # Generate signed params
+ path = "clientSecret"
+ expires = Time.syncedISO(seconds=SIGNATURE_EXPIRES_OFFSET_SECONDS)
+ hash_user_id = ""
+ M = str("%s%s%s%s%s" % (path, Keys.app_id, hash_mpin_id_hex, hash_user_id, expires))
+ signature_hex = signMessage(M, Keys.app_key)
+
+ param_values = {
+ 'app_id': Keys.app_id,
+ 'expires': expires,
+ 'hash_mpin_id': hash_mpin_id_hex,
+ 'hash_user_id': hash_user_id,
+ 'mobile': mobile,
+ 'signature': signature_hex,
+ }
+
+ url = "{0}/{1}".format(options.DTALocalURL.rstrip("/"), path)
+ urlParams = url_concat(url, param_values)
+
+ client = tornado.httpclient.AsyncHTTPClient()
+ response = yield tornado.gen.Task(client.fetch, urlParams, method="GET")
+
+ if response.error:
+ log.error("DTA clientSecret failed, URL: {0}. Code: {1}, Reason: {2}".format(urlParams, response.code, response.reason))
+ self.set_status(500)
+ self.finish()
+ return
+
+ if response.body:
+ try:
+ responseData = json.loads(response.body)
+ clientSecretShare = responseData["clientSecret"]
+ except:
+ log.error("DTA /clientSecret Failed. Invalid JSON response".format(response.body))
+ self.set_status(500)
+ self.finish()
+ return
+
+ I.delete()
+
+ params = urllib.urlencode(param_values)
+ data = {
+ "params": params,
+ "clientSecretShare": clientSecretShare
+ }
+
+ self.write(data)
+ self.finish()
+
+
+class RPSTimePermitHandler(BaseHandler):
+
+ def __init__(self, *args, **kwargs):
+ super(RPSTimePermitHandler, self).__init__(*args, **kwargs)
+ self.http_client = tornado.httpclient.AsyncHTTPClient()
+
+ @tornado.gen.coroutine
+ def get_time_permits(self, hash_mpin_id_hex, signature):
+ # Get time permit from the local D-TA
+ url = url_concat(
+ "{0}/{1}".format(options.DTALocalURL.rstrip("/"), "timePermits"), {
+ 'hash_mpin_id': hash_mpin_id_hex,
+ 'signature': signature,
+ 'count': random.randint(PERMITS_MIN, PERMITS_MAX) if options.cacheTimePermits else 1})
+ response = yield self.http_client.fetch(url)
+
+ if response.error:
+ log.error("DTA timePermit failed, URL: {0}. Code: {1}, Reason: {2}".format(url, response.code, response.reason))
+ raise HTTPError(500)
+
+ if response.body:
+ try:
+ response_data = json.loads(response.body)
+ raise tornado.gen.Return(response_data["timePermits"])
+ except (ValueError, KeyError):
+ log.error("DTA /timePermit Failed. Invalid JSON response".format(
+ response.body))
+ raise HTTPError(500)
+
+ def cache_time_permits(self, time_permits, hash_mpin_id_hex):
+ # Cache them in storage
+ for date_epoch, time_permit in time_permits.iteritems():
+ try:
+ date_epoch = int(date_epoch)
+ except ValueError:
+ log.error("DTA /timePermit Failed. Date invalid integer")
+ raise HTTPError(500)
+
+ self.storage.add(
+ expire_time=datetime.datetime.fromtimestamp(date_epoch * 60 * 1440) + datetime.timedelta(days=1),
+ time_permit_id=hash_mpin_id_hex,
+ time_permit_date=date_epoch,
+ time_permit=time_permit)
+
+ @tornado.gen.coroutine
+ def get_time_permit(self, hash_mpin_id_hex, date_epoch, signature):
+ """Get time permit from cache or request new."""
+ if options.cacheTimePermits:
+ time_permit_item = self.storage.find(time_permit_id=hash_mpin_id_hex, time_permit_date=date_epoch)
+ if time_permit_item:
+ # Get time permit from cache
+ raise tornado.gen.Return(time_permit_item.time_permit)
+
+ # No cached time permits for this mpin id, request new from D-TA
+ time_permits = yield self.get_time_permits(hash_mpin_id_hex, signature)
+ if options.cacheTimePermits:
+ self.cache_time_permits(time_permits, hash_mpin_id_hex)
+
+ # Return the one for today
+ if str(date_epoch) not in time_permits:
+ log.error("DTA /timePermit Failed. No time permit for today")
+ raise HTTPError(500)
+ raise tornado.gen.Return(time_permits[str(date_epoch)])
+
+ @tornado.gen.coroutine
+ def get(self, mpin_id):
+ # Check revocation status of mpin id.
+ if options.RPAPermitUserURL:
+ response = yield self.http_client.fetch(
+ url_concat(options.RPAPermitUserURL, {"mpin_id": mpin_id}),
+ raise_error=False)
+
+ if response.code != 200:
+ # RPA rejects this mpin id
+ raise HTTPError(response.code)
+
+ hash_mpin_id_hex = hashlib.sha256(mpin_id.decode("hex")).hexdigest()
+ today_epoch = secrets.today()
+ signature = signMessage(hash_mpin_id_hex, Keys.app_key)
+ time_permit = yield self.get_time_permit(hash_mpin_id_hex, today_epoch, signature)
+
+ self.set_header("Cache-Control", "no-cache")
+ self.finish({
+ "date": today_epoch,
+ "signature": signature,
+ "storageId": hash_mpin_id_hex,
+ 'message': "M-Pin Time Permit Generated",
+ 'timePermit': time_permit,
+ 'version': VERSION,
+ })
+
+
+class RPSSetupDoneHandler(BaseHandler):
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def post(self, mpinId):
+ log.debug("Setup done for mpinId: {0}".format(mpinId))
+ self.set_status(200)
+ self.finish()
+
+
+class RPSGetAccessNumberHandler(BaseHandler):
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def post(self):
+ # Generate request for MPinWIDServer for WID
+ wId = secrets.generate_random_webid(self.application.server_secret.rng, options.accessNumberUseCheckSum)
+
+ while wId is None or (self.storage.find(stage="auth", webID=wId)):
+ if wId is None:
+ log.debug("WebId is None".format(wId))
+ else:
+ log.debug("WebId {0} already exists. Generating a new one".format(wId))
+ wId = secrets.generate_random_webid(self.application.server_secret.rng, options.accessNumberUseCheckSum)
+
+ log.debug("New webId generated: {0}." .format(wId))
+
+ webOTT = secrets.generate_ott(options.OTTLength, self.application.server_secret.rng, "hex")
+
+ nowTime = Time.syncedNow()
+ expirePinPadTime = nowTime + datetime.timedelta(seconds=options.accessNumberExpireSeconds)
+ expireTime = expirePinPadTime + datetime.timedelta(seconds=options.accessNumberExtendValiditySeconds)
+
+ self.storage.add(stage="auth", expire_time=expireTime, webOTT=webOTT, wid=wId)
+
+ params = {
+ "ttlSeconds": options.accessNumberExpireSeconds,
+ "accessNumber": wId,
+ "webOTT": webOTT,
+ "localTimeStart": Time.DateTimetoEpoch(nowTime),
+ "localTimeEnd": Time.DateTimetoEpoch(expirePinPadTime)
+ }
+
+ self.write(params)
+ self.finish()
+
+
+class RPSAccessNumberHandler(BaseHandler):
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def post(self):
+ try:
+ data = json.loads(self.request.body)
+ webOTT = data["webOTT"]
+ except ValueError:
+ log.error("Cannot decode body as JSON.")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+
+ I = self.storage.find(stage="auth", webOTT=webOTT)
+ if not I:
+ log.debug("Cannot find webOTT: {0}".format(webOTT))
+
+ self.set_status(404)
+ self.finish()
+ return
+
+ authOTT = I.authOTT
+ if authOTT and (str(I.status) == "200"):
+ self.write({"authOTT": authOTT})
+ self.finish()
+ else:
+ if not authOTT:
+ log.debug("authOTT not set for webOTT: {0}".format(webOTT))
+ else:
+ log.debug("Auth status for webOTT: {0}: {1}".format(webOTT, I.status))
+ self.set_status(401)
+ self.finish()
+
+
+class RPSAuthenticateHandler(BaseHandler):
+
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def post(self):
+ try:
+ data = json.loads(self.request.body)
+ data = data["mpinResponse"]
+ authOTT = data["authOTT"]
+ except ValueError:
+ log.error("Cannot decode body as JSON.")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+ except KeyError:
+ log.error("Invalid JSON data structure")
+ log.debug(data)
+ self.set_status(400, reason="BAD REQUEST. INVALID DATA")
+ self.finish()
+ return
+
+ I = self.storage.find(stage="auth", authOTT=authOTT)
+
+ if not I:
+ log.error("Invalid or expired authOTT")
+ status = 412
+ message = "Invalid or expired access number"
+ userId = ""
+ mpinId = ""
+ response = {"message": message}
+
+ else:
+ authToken = I.authToken
+ mpinId = authToken["mpin_id"].encode("hex")
+ identity = json.loads(authToken["mpin_id"])
+ userId = identity["userID"]
+
+ (fail, status, reason) = verifyToken(authToken)
+
+ aI = self.storage.find(stage="attempts", mpinId=mpinId)
+ attemptsCount = aI and aI.attemptsCount or 0
+ if attemptsCount >= options.maxInvalidLoginAttempts:
+ fail = 1
+
+ if fail == 0:
+ # Delete invalid login attempts if any
+ if aI:
+ aI.delete()
+
+ # Authentication successful
+ message = "Authentication successful"
+
+ # Wait for browser authentication and get Logout information
+ I.update(status=status, message=message)
+
+ main_loop = tornado.ioloop.IOLoop.instance()
+
+ # Wait until the browser is ready or timeout occurs
+ timeOut = Time.syncedNow(seconds=MOBILE_LOGIN_AUTHENTICATION_TIMEOUT_SECONDS)
+ while (not I.browserReady) and (Time.syncedNow() < timeOut):
+ yield tornado.gen.Task(main_loop.add_timeout, time.time() + 1)
+
+ # Get the new status. Status can be changed with the /loginResult request
+ if status != I.status:
+ status = I.status
+ message = I.message
+ response = {"message": message}
+
+ elif I.browserReady:
+ logoutURL = I.logoutURL or ""
+ logoutData = I.logoutData or ""
+ response = {"logoutURL": logoutURL, "logoutData": logoutData}
+ else:
+ # Timeout occured
+ status = 408
+ message = "Authentication timed out"
+ response = {"message": message}
+
+ else:
+ attemptsCount += 1
+ if aI:
+ aI.update(attemptsCount=attemptsCount)
+ else:
+ self.storage.add(stage="attempts", mpinId=mpinId, attemptsCount=attemptsCount)
+
+ if attemptsCount >= options.maxInvalidLoginAttempts:
+ status = 410
+
+ log.debug("Wrong PIN for user {0}.".format(userId))
+ message = "Wrong PIN."
+ response = {"message": message}
+ I.update(status=status, message=message)
+
+ self.set_status(status, message)
+ self.write(response)
+ self.finish()
+
+
+class StatusHandler(BaseHandler):
+
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def get(self):
+ reason = "active"
+ self.set_status(200, reason=reason)
+ self.write({'version': VERSION, 'message': reason})
+
+ self.finish()
+
+
+class DefaultHandler(BaseHandler):
+ def get(self, input):
+ reason = "URI NOT FOUND"
+ self.set_status(404, reason=reason)
+ self.write({'version': VERSION, 'service_name': 'M-Pin authentication server', 'message': reason})
+ return
+
+ def post(self, input):
+ reason = "URI NOT FOUND"
+ self.set_status(404, reason=reason)
+ self.write({'version': VERSION, 'service_name': 'M-Pin authentication server', 'message': reason})
+ return
+
+ def put(self, input):
+ reason = "URI NOT FOUND"
+ self.set_status(404, reason=reason)
+ self.write({'version': VERSION, 'service_name': 'M-Pin authentication server', 'message': reason})
+ return
+
+ def delete(self, input):
+ reason = "URI NOT FOUND"
+ self.set_status(404, reason=reason)
+ self.write({'version': VERSION, 'service_name': 'M-Pin authentication server', 'message': reason})
+ return
+
+
+# AUTHENTICATION HANDLER
+class Pass1Handler(BaseHandler):
+ """
+ .. apiTextStart
+
+ *Description*
+
+ Implements the first pass of the M-Pin Protocol
+
+ *URL structure*
+
+ ``/pass1``
+
+ *Version*
+
+ 0.3
+
+ *HTTP Request Method*
+
+ POST
+
+ *Request Data*
+
+ JSON request::
+
+ {
+ "mpin_id": "7b22...227d",
+ "U": "0409...3d9c",
+ "UT": "0402...d1d1",
+ "pass" : 1
+ }
+
+ mpin_id is the hex encoded M-Pin ID, U is x.hash(mpin_id) and UT is
+ x.(hash(mpin_id) + hash(data||hash(mpin_id)))
+
+ *Returns*
+
+ JSON response::
+
+ {
+ "y" : "212a...8d08",
+ "version" : "0.3",
+ "message" : "OK",
+ "pass" : 1
+ }
+
+ y is a 256 bit random value.
+
+ *Status-Codes and Response-Phrases*
+
+ ::
+
+ Status-Code Response-Phrase
+
+ 200 OK
+ 403 Invalid data received. <argument> argument missing
+ 403 Invalid data received. No JSON object could be decoded
+ 403 Invalid data received. Non-hexadecimal digit found
+ 500 Failed to generate y
+ 500 Failed to add pass one to memory
+
+ .. apiTextEnd
+
+ """
+ def post(self):
+ # Remote request information
+ if 'User-Agent' in self.request.headers.keys():
+ UA = self.request.headers['User-Agent']
+ else:
+ UA = 'unknown'
+ request_info = '%s %s %s %s ' % (self.request.path, self.request.remote_ip, UA, Time.syncedISO())
+
+ try:
+ receive_data = tornado.escape.json_decode(self.request.body)
+ mpin_id = receive_data['mpin_id'].decode("hex")
+ ut_hex = receive_data['UT']
+ u_hex = receive_data['U']
+ except KeyError as ex:
+ reason = "Invalid data received. %s argument missing" % ex.message
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(403, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'version': VERSION, 'message': reason})
+ self.finish()
+ return
+ except (ValueError, TypeError) as ex:
+ reason = "Invalid data received. %s" % ex.message
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(403, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'version': VERSION, 'message': reason})
+ self.finish()
+ return
+ log.debug("%s %s" % (request_info, receive_data))
+
+ # Server generates Random number Y and sends it to Client
+ try:
+ y_hex = self.application.server_secret.get_pass1_value()
+ except secrets.SecretsError as e:
+ log.error(e.message)
+ self.set_status(500, reason=e.message)
+ self.content_type = 'application/json'
+ self.write({'version': VERSION, 'message': e.message})
+ self.finish()
+ return
+
+ # Store Pass1 values
+ self.storage.add(
+ expire_time=Time.syncedISO(seconds=PASS1_EXPIRES_TIME),
+ stage="pass1",
+ mpinId=mpin_id.encode('hex'),
+ ut=ut_hex,
+ u=u_hex,
+ y=y_hex,
+ )
+
+ log.info("%s Stored Pass1 values" % request_info)
+
+ reason = "OK"
+ self.set_status(200, reason=reason)
+ self.content_type = 'application/json'
+ return_data = {
+ 'version': VERSION,
+ 'y': y_hex,
+ 'pass': 1,
+ 'message': reason
+ }
+ log.debug("%s %s" % (request_info, return_data))
+ self.write(return_data)
+ self.finish()
+ return
+
+
+class Pass2Handler(BaseHandler):
+ """
+ .. apiTextStart
+
+ *Description*
+
+ Implements the second pass of the M-Pin Protocol. The result will be the authOTP.
+ At this point the authentication token has also been written to the RPS.
+ An authOTT will always be returned even if authentication fails.
+
+ *URL structure*
+
+ ``/pass2``
+
+ *Version*
+
+ 0.3
+
+ *HTTP Request Method*
+
+ POST
+
+ *Request Data*
+
+ JSON request::
+
+ {
+ "WID" : "123456"
+ "V" : "0411...05f6a",
+ "pass" : 2,
+ "OTP" : <1||0>
+ }
+
+ WID is web identifier used for mobile authentication
+ When OTP is set to one this indicates that the radius OTP should be
+ generated. V is a parameter used to perform the final step of the M-Pin
+ algorithm.
+
+ *Returns*
+
+ JSON response::
+
+ {
+ "OTP": "155317",
+ "authOTT": "31ba0ed5efb75d91ef69a2b7eb1d3a26",
+ "pass": 2,
+ "version": "0.3"
+ }
+
+ OTP is the radius one time password. authOTT is the password used to log into the
+ Customer's website.
+
+ *Status-Codes and Response-Phrases*
+
+ ::
+
+ Status-Code Response-Phrase
+
+ 200 OK
+ 403 Invalid data received. <argument> argument missing
+ 403 Invalid data received. No JSON object could be decoded
+ 403 Invalid data received. Non-hexadecimal digit found
+ 500 Pass one data is not in memory
+
+ .. apiTextEnd
+
+ """
+ @tornado.gen.coroutine
+ def post(self):
+ # Remote request information
+ if 'User-Agent' in self.request.headers.keys():
+ UA = self.request.headers['User-Agent']
+ else:
+ UA = 'unknown'
+ request_info = '%s %s %s %s ' % (self.request.path, self.request.remote_ip, UA, Time.syncedISO())
+
+ try:
+ receive_data = tornado.escape.json_decode(self.request.body)
+ mpin_id_hex = receive_data['mpin_id']
+ mpin_id = mpin_id_hex.decode('hex')
+ WID = receive_data['WID']
+ OTPEn = receive_data['OTP']
+ v_data = receive_data['V'].decode("hex")
+ except KeyError as ex:
+ reason = "Invalid data received. %s argument missing" % ex.message
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(403, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'version': VERSION, 'message': reason})
+ self.finish()
+ return
+ except (ValueError, TypeError) as ex:
+ reason = "Invalid data received. %s" % ex.message
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(403, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'version': VERSION, 'message': reason})
+ self.finish()
+ return
+ log.debug("%s %s" % (request_info, receive_data))
+
+ # Get pass one values
+ pass1Value = self.storage.find(stage="pass1", mpinId=mpin_id_hex)
+
+ if pass1Value:
+ u = pass1Value.u.decode("hex")
+ ut = pass1Value.ut.decode("hex")
+ y = pass1Value.y.decode("hex")
+ else:
+ reason = "Invalid pass one data"
+ log.error("%s %s" % (request_info, reason))
+ self.set_status(500, reason=reason)
+ self.content_type = 'application/json'
+ self.write({'version': VERSION, 'message': reason})
+ self.finish()
+ return
+ log.info("%s loaded Pass1 values" % request_info)
+
+ # Generate OTP value
+ if int(OTPEn) == 1:
+ OTP = "{0:06d}".format(
+ secrets.generate_otp(self.application.server_secret.rng))
+ else:
+ OTP = '0'
+
+ log.info("%s generate OTP" % request_info)
+
+ successCode = self.application.server_secret.validate_pass2_value(
+ mpin_id, u, ut, y, v_data)
+
+ pinError = 0
+ pinErrorCost = 0
+
+ # Authentication Token expiry
+ expires = Time.syncedISO(seconds=SIGNATURE_EXPIRES_OFFSET_SECONDS)
+
+ # Form Authentication token
+ token = {
+ "mpin_id": mpin_id,
+ "mpin_id_hex": mpin_id_hex,
+ "successCode": successCode,
+ "pinError": pinError,
+ "pinErrorCost": pinErrorCost,
+ "expires": expires,
+ "WID": WID,
+ "OTP": OTP
+ }
+ log.debug("%s M-Pin Auth token: %s" % (request_info, token))
+
+ # Form authentication 128 hex encoded One Time Password
+ authOTT = secrets.generate_auth_ott(self.application.server_secret.rng)
+
+ # Form message to return to client #
+ return_data = {
+ 'version': VERSION,
+ 'pass': 2,
+ 'authOTT': authOTT
+ }
+
+ if int(OTPEn) == 1:
+ return_data['OTP'] = OTP
+
+ if WID != "0":
+ # Login with mobile
+ I = self.storage.find(stage="auth", wid=WID)
+
+ wid_flow = "wid"
+ flow = "mobile"
+
+ # if not I:
+ # log.error("Invalid or expired access number: {0} for mpinid: {1}".format(WID, mpinId))
+ # self.set_status(412, reason="INVALID OR EXPIRED ACCESS NUMBER")
+ # self.finish()
+ # return
+
+ if I:
+ I.update(authOTT=authOTT, mpinid=mpin_id, authToken=token)
+
+ else:
+ wid_flow = "browser"
+
+ if int(token.get("OTP", "0")) != 0:
+ flow = "OTP"
+ else:
+ flow = "Browser"
+
+ self.storage.add(
+ expire_time=Time.ISOtoDateTime(expires),
+ stage="auth",
+ authOTT=authOTT,
+ mpinId=mpin_id,
+ wid="",
+ webOTT=0,
+ authToken=token
+ )
+
+ log.debug("New M-Pin Authentication token / {0}. Flow: {1}".format(wid_flow, flow))
+
+ # Always send 200 to PIN Pad even if the user is not authenticated
+ reason = "OK"
+ log.debug("%s %s" % (request_info, return_data))
+ self.set_status(200, reason=reason)
+ self.content_type = 'application/json'
+ self.write(return_data)
+ self.finish()
+ return
+
+
+# PRIVATE HANDLERS
+class ManageGetStackInfoHandler(PrivateBaseHandler):
+
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def get(self):
+ # Get signed API settings
+
+ path = "apiSettings"
+ expires = Time.syncedISO(seconds=SIGNATURE_EXPIRES_OFFSET_SECONDS)
+ M = "{0}{1}{2}" .format(path, Keys.app_id, expires)
+ signature_hex = signMessage(M, Keys.app_key)
+
+ param_values = {
+ 'app_id': Keys.app_id,
+ 'expires': expires,
+ 'signature': signature_hex,
+ }
+
+ url = "{0}/{1}".format(Keys.api_url.rstrip("/"), path)
+ urlParams = url_concat(url, param_values)
+
+ client = tornado.httpclient.AsyncHTTPClient()
+ response = yield tornado.gen.Task(client.fetch, urlParams, method="GET")
+
+ status = 200
+
+ if (response.error):
+ log.error("API Request Error URL: {0}: {1}: {2}".format(url, response.error, response.body))
+ status = 500
+ self.set_status(status)
+ self.finish()
+ return
+
+ resp_data = None
+ try:
+ resp_data = json.loads(response.body)
+ except ValueError:
+ log.error("Cannot decode JSON response from API {0}: {1}".format(url, response.body))
+ status = 500
+ self.set_status(status)
+ self.finish()
+ return
+
+ expires = Time.syncedISO(hours=1)
+ M = "{0}{1}" .format(Keys.app_id, expires)
+ signature = signMessage(M, Keys.app_key)
+
+ manage_auth_params = {
+ "signature": signature,
+ "expires": expires,
+ "app_id": Keys.app_id
+ }
+
+ result = {
+ "managementConsoleURL": resp_data.get("managementConsoleURL", ""),
+ "license_info": resp_data.get("license_info", {}),
+ "manage_auth": manage_auth_params
+ }
+
+ self.write(result)
+ self.finish()
+
+
+class UserHandler(PrivateBaseHandler):
+ def post(self, mpinId):
+ log.debug("Request for activationg mpinid: {0}".format(mpinId))
+
+ try:
+ data = json.loads(self.request.body)
+ activateKey = data["activateKey"]
+ except:
+ log.error("Invalid JSON request: {0}".format(self.request.body))
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+
+ I = self.storage.find(stage="register", mpinId=mpinId)
+ if not I:
+ log.debug("MpinID {0} not found.".format(mpinId))
+ self.set_status(401, "M-Pin ID not found.")
+ self.finish()
+ return
+
+ I.update(active=activateKey)
+ self.set_status(200)
+ self.finish()
+
+
+class AuthenticateHandler(PrivateBaseHandler):
+
+ @tornado.web.asynchronous
+ @tornado.gen.engine
+ def post(self):
+ OTP = ""
+
+ try:
+ data = json.loads(self.request.body)
+ authOTT = data["authOTT"]
+ except ValueError:
+ log.error("Cannot decode body as JSON.")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+ except KeyError:
+ log.error("Invalid JSON data structure")
+ log.debug(data)
+ self.set_status(400, reason="BAD REQUEST. INVALID DATA")
+ self.finish()
+ return
+
+ I = self.storage.find(stage="auth", authOTT=authOTT)
+
+ if not I:
+ log.error("Invalid or expired authOTT: {0}".format(authOTT))
+ status = 408
+ message = "Expired authentication request"
+ userId = ""
+ mpinId = ""
+ else:
+ authToken = I.authToken
+ mpinId = authToken["mpin_id"].encode("hex")
+ identity = json.loads(authToken["mpin_id"])
+ userId = identity["userID"]
+
+ if authToken.get("OTP", "0") != "0":
+ OTP = authToken["OTP"]
+
+ log.debug("authToken: {0}".format(authToken))
+
+ if I.status:
+ # Mobile authentication, status already set
+ status = I.status
+ message = I.message
+
+ # get logout data
+ logoutURL = data.get("logoutURL") or options.LogoutURL
+ logoutData = data.get("logoutData")
+ # If logoutURL is set, the mobile app will make a request to that URL
+ # If logoutData is set, the request method will be POST otherwise it will be GET
+
+ # If option waitForLoginResult is set, browserReady flag will be set on /loginResult request
+ # from the RPA
+ browserReady = (not options.waitForLoginResult)
+
+ I.update(logoutData=logoutData, logoutURL=logoutURL, browserReady=browserReady)
+
+ if not options.waitForLoginResult:
+ I.delete()
+ else:
+ (fail, status, reason) = verifyToken(authToken)
+
+ aI = self.storage.find(stage="attempts", mpinId=mpinId)
+ log.debug("aI: {0}".format(aI))
+ attemptsCount = aI and aI.attemptsCount or 0
+ log.debug("attemptsCount: {0}".format(attemptsCount))
+ if attemptsCount >= options.maxInvalidLoginAttempts:
+ fail = 1
+
+ if fail == 0:
+ # Authentication successful
+ message = "Authentication successful"
+ if aI:
+ aI.delete()
+ else:
+ attemptsCount += 1
+ if aI:
+ aI.update(attemptsCount=attemptsCount)
+ else:
+ self.storage.add(stage="attempts", mpinId=mpinId, attemptsCount=attemptsCount)
+
+ if attemptsCount >= options.maxInvalidLoginAttempts:
+ status = 410
+
+ log.debug("Wrong PIN for user {0}.".format(userId))
+ message = "Wrong PIN."
+
+ if not options.waitForLoginResult:
+ I.delete()
+
+ returnData = {
+ "userId": userId,
+ "mpinId": mpinId,
+ "status": status,
+ "message": message
+ }
+
+ if OTP:
+ returnData["OTP"] = OTP
+
+ self.set_status(status, message)
+ self.write(returnData)
+ self.finish()
+
+
+class LoginResultHandler(PrivateBaseHandler):
+
+ def post(self):
+ if not options.waitForLoginResult:
+ self.set_status(404)
+ self.finish()
+ return
+
+ try:
+ data = json.loads(self.request.body)
+ authOTT = data["authOTT"]
+ status = data["status"]
+ except ValueError:
+ log.error("Cannot decode body as JSON.")
+ log.debug(self.request.body)
+ self.set_status(400, reason="BAD REQUEST. INVALID JSON")
+ self.finish()
+ return
+ except KeyError:
+ log.error("Invalid JSON data structure")
+ log.debug(data)
+ self.set_status(400, reason="BAD REQUEST. INVALID DATA")
+ self.finish()
+ return
+
+ I = self.storage.find(stage="auth", authOTT=authOTT)
+
+ if not I:
+ log.error("Invalid or expired authOTT")
+ self.set_status(408, reason="Invalid or expired authOTT")
+ self.finish()
+ return
+
+ if int(status) != 200:
+ I.update(status=status, message=data.get("message", I.message), browserReady=True)
+ else:
+ # Get the logout data
+ # Logout data can be set on the previous /authenticate request as well.
+ logoutURL = data.get("logoutURL") or I.logoutURL or options.LogoutURL
+ logoutData = data.get("logoutData") or I.logoutData
+ # If logoutURL is set, the mobile app will make a request to that URL
+ # If logoutData is set, the request method will be POST otherwise it will be GET
+
+ I.update(logoutData=logoutData, logoutURL=logoutURL, browserReady=True)
+
+ I.delete()
+
+
+class DynamicOptionsHandler(PrivateBaseHandler):
+ @tornado.web.asynchronous
+ def post(self):
+ if options.dynamicOptionsURL:
+ process_dynamic_options(
+ DYNAMIC_OPTION_MAPPING,
+ DYNAMIC_OPTION_HANDLERS,
+ application=self.application)
+ self.set_status(200, 'OK')
+ else:
+ self.set_status(403, 'Dynamic options are disabled')
+ self.finish()
+
+ def get(self):
+ if options.dynamicOptionsURL:
+ self.set_status(200, 'OK')
+ self.write(generate_dynamic_options(DYNAMIC_OPTION_MAPPING))
+ else:
+ self.set_status(403, 'Dynamic options are disabled')
+
+
+class MobileConfigHandler(BaseHandler):
+ def get(self):
+ if not options.mobileConfig:
+ self.set_status(403, 'No config is available')
+ elif not options.mobileUseNative:
+ self.set_status(404, 'Native client is disabled')
+ else:
+ self.set_status(200, 'OK')
+ self.write(json.dumps(options.mobileConfig))
+
+
+# MAIN
+class Application(tornado.web.Application):
+ def __init__(self):
+ rpsPrefix = options.rpsPrefix.strip("/")
+ handlers = [
+ (r"/user/([0-9A-Fa-f]+)", UserHandler), # POST
+ (r"/{0}/user(/?[0-9A-Fa-f]*)".format(rpsPrefix), RPSUserHandler), # PUT
+ (r"/{0}/signature/([0-9A-Fa-f]+)".format(rpsPrefix), RPSSignatureHandler), # GET
+ (r"/{0}/timePermit/([0-9A-Fa-f]+)".format(rpsPrefix), RPSTimePermitHandler), # GET
+ (r"/{0}/setupDone/([0-9A-Fa-f]+)".format(rpsPrefix), RPSSetupDoneHandler), # POST
+ (r"/{0}/accessnumber".format(rpsPrefix), RPSAccessNumberHandler), # POST
+ (r"/{0}/getAccessNumber".format(rpsPrefix), RPSGetAccessNumberHandler), # POST
+ (r"/{0}/clientSettings".format(rpsPrefix), ClientSettingsHandler),
+ (r"/{0}/authenticate".format(rpsPrefix), RPSAuthenticateHandler), # POST, for mobile login
+ # Authentication
+ (r"/{0}/pass1".format(rpsPrefix), Pass1Handler),
+ (r"/{0}/pass2".format(rpsPrefix), Pass2Handler),
+
+ (r"/authenticate", AuthenticateHandler), # POST
+
+ (r"/manage/getStackInfo", ManageGetStackInfoHandler), # GET
+
+ (r"/loginResult", LoginResultHandler), # POST
+
+ (r"/status", StatusHandler),
+ (r"/dynamicOptions", DynamicOptionsHandler), # POST, GET
+ (r"/{0}/mobileConfig".format(rpsPrefix), MobileConfigHandler), # GET
+ (r"/(.*)", DefaultHandler),
+ ]
+ settings = {}
+ super(Application, self).__init__(handlers, **settings)
+
+ Seed.getSeed(options.EntropySources) # Get seed value for random number generator
+ self.server_secret = secrets.ServerSecret(
+ Seed.seedValue,
+ Keys.app_id,
+ Keys.app_key)
+
+ log.debug("Using storage: {0}".format(options.storage))
+ storage_cls = get_storage_cls()
+ self.storage = storage_cls(
+ tornado.ioloop.IOLoop.instance(),
+ "stage,mpinId",
+ "stage,authOTT",
+ "stage,wid",
+ "stage,webOTT",
+ "time_permit_id,time_permit_date"
+ )
+
+
+def main():
+ options.parse_command_line()
+
+ if os.path.exists(options.configFile):
+ try:
+ options.parse_config_file(options.configFile)
+ options.parse_command_line()
+ except Exception, E:
+ print("Invalid config file {0}".format(options.configFile))
+ print(E)
+ sys.exit(1)
+
+ # Set Log level
+ log.setLevel(getLogLevel(options.logLevel))
+
+ detectProxy()
+
+ # Load the credentials from file
+ log.info("Loading credentials")
+ try:
+ credentialsFile = options.credentialsFile
+ Keys.loadFromFile(credentialsFile)
+ except Exception as E:
+ log.error("Error opening the credentials file: {0}".format(credentialsFile))
+ log.error(E)
+ sys.exit(1)
+
+ # TMP fix for 'ValueError: I/O operation on closed epoll fd'
+ # Fixed in Tornado 4.2
+ tornado.ioloop.IOLoop.instance()
+
+ # Sync time to CertiVox time server
+ if options.syncTime:
+ Time.getTime(wait=True)
+
+ Keys.getAPISettings(wait=True)
+
+ log.info("Server starting on {0}:{1}...".format(options.address, options.port))
+
+ http_server = Application()
+ http_server.listen(options.port, options.address, xheaders=True)
+ main_loop = tornado.ioloop.IOLoop.instance()
+ http_server.io_loop = main_loop
+
+ if options.autoReload:
+ log.debug("Starting autoreloader")
+
+ tornado.autoreload.watch(CONFIG_FILE)
+ tornado.autoreload.start(main_loop)
+
+ process_dynamic_options(
+ DYNAMIC_OPTION_MAPPING,
+ DYNAMIC_OPTION_HANDLERS,
+ application=http_server,
+ initial=True)
+
+ log.info("Server started. Listening on {0}:{1}".format(options.address, options.port))
+ main_loop.start()
+
+
+class ServiceDaemon(Daemon):
+ def run(self):
+ main()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1].lower() in ("start", "stop"):
+ action = sys.argv.pop(1)
+ logFile = os.path.join(BASE_DIR, "rps.log")
+ pidFile = os.path.join(BASE_DIR, "rps.pid")
+
+ daemon = ServiceDaemon(pidfile=pidFile, stdout=logFile, stderr=logFile)
+ if action == "start":
+ log.info("Starting as daemon. Log file: {0}".format(logFile))
+ daemon.start()
+ elif action == "stop":
+ log.info("Stopping daemon...")
+ daemon.stop()
+ sys.exit()
+ else:
+ try:
+ main()
+ except Exception as e:
+ log.error(str(e))
+ sys.exit(1)
diff --git a/servers/rps/rpsservice.py b/servers/rps/rpsservice.py
new file mode 100755
index 0000000..43c18b4
--- /dev/null
+++ b/servers/rps/rpsservice.py
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import sys
+
+if (os.name != "nt"):
+ print "This file can be run on Windows."
+ sys.exit(1)
+
+from mpWinService import installService
+import rps
+
+
+installService(rps.ServiceDaemon, 'mpinRPS', 'M-Pin RPS Service')
diff --git a/servers/rps/test_dynamic_options.py b/servers/rps/test_dynamic_options.py
new file mode 100644
index 0000000..7a035ed
--- /dev/null
+++ b/servers/rps/test_dynamic_options.py
@@ -0,0 +1,85 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import print_function, unicode_literals
+from dynamic_options import _test_dynamic_options_update
+from rps import DYNAMIC_OPTION_MAPPING
+
+
+def test_enable_sync():
+
+ _test_dynamic_options_update(
+ initial_options={'syncTime': False, 'timePeriod': 1000},
+ response_body='{"time_synchronization": true}',
+ expected_options={'syncTime': True, 'timePeriod': 1000},
+ local_mapping=DYNAMIC_OPTION_MAPPING)
+
+
+def test_change_period():
+ _test_dynamic_options_update(
+ initial_options={'syncTime': True, 'timePeriod': 1000},
+ response_body='{"time_synchronization_period": 2000}',
+ expected_options={'syncTime': True, 'timePeriod': 2000},
+ local_mapping=DYNAMIC_OPTION_MAPPING)
+
+
+def test_disable_sync():
+ _test_dynamic_options_update(
+ initial_options={'syncTime': True, 'timePeriod': 1000},
+ response_body='{"time_synchronization": false}',
+ expected_options={'syncTime': False, 'timePeriod': 1000},
+ local_mapping=DYNAMIC_OPTION_MAPPING)
+
+
+def test_mobile_change():
+ _test_dynamic_options_update(
+ initial_options={
+ "mobileUseNative": True,
+ "mobileConfig": [{
+ "mobile_otp_name": "Foo",
+ "mobile_otp_url": "foo",
+ "mobile_otp_type": "otpa",
+ "mobile_online_name": "Spam",
+ "mobile_online_url": "spam",
+ "mobile_online_type": "onlinea",
+ }],
+ },
+ response_body="""
+ {
+ "mobile_use_native": false,
+ "mobile_client_config": [{
+ "mobile_otp_name": "Bar",
+ "mobile_otp_url": "bar",
+ "mobile_otp_type": "otpb",
+ "mobile_online_name": "Eggs",
+ "mobile_online_url": "eggs",
+ "mobile_online_type": "onlineb"
+ }]
+ }
+ """,
+ expected_options={
+ "mobileUseNative": False,
+ "mobileConfig": [{
+ "mobile_otp_name": "Bar",
+ "mobile_otp_url": "bar",
+ "mobile_otp_type": 'otpb',
+ "mobile_online_name": "Eggs",
+ "mobile_online_url": "eggs",
+ "mobile_online_type": "onlineb",
+ }],
+ },
+ local_mapping=DYNAMIC_OPTION_MAPPING)
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..ca130b7
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from setuptools import setup, find_packages
+
+setup(
+ name="M-Pin Backend",
+ version="0.1",
+ packages=find_packages(),
+ install_requires=[
+ 'cffi==0.9.0',
+ 'pbkdf2==1.3',
+ 'python-dateutil==2.4.2',
+ 'redis==2.10.3',
+ 'tornado==4.1',
+ ],
+ author="CertiVox",
+ author_email="support@miracl.com",
+ description="M-Pin Backend services",
+ url="https://github.com/CertiVox/mpin-backend",
+)
diff --git a/tests/test_crypto.py b/tests/test_crypto.py
new file mode 100644
index 0000000..a26c7c6
--- /dev/null
+++ b/tests/test_crypto.py
@@ -0,0 +1,138 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for crypto mapping functions."""
+import calendar
+import datetime
+import time
+
+from pbkdf2 import PBKDF2
+
+import mpin
+import crypto
+
+
+def test_today():
+ """Get today."""
+ today = crypto.today()
+ assert type(today) == int
+ assert today == calendar.timegm(time.gmtime()) / (60 * 1440)
+
+
+def test_auth_success():
+ """Test the basic authentication flow."""
+ seed = open('/dev/urandom', 'rb').read(100)
+ RNG = crypto.get_random_generator(seed)
+
+ y = crypto.mpin_random_generate(RNG)
+
+ date = 16238
+ PIN = 1234
+ mpin_id = '{"mobile": 1, "issued": "2013-10-19T06:12:28Z", "userID": "testUser0@certivox.com", "salt": "e0842acc8cc38fc4"}'
+
+ token = "040d73e9b7c746525edbb90e042a6b8f6e41e2417a0c0c1600f8b693c0b6c0bbce0c61b73100798c7505b3eb12c2393187355145090333f904fd896684ec4990d3".decode("hex")
+ timePermit = "0408ba13483e817626a45be598b1b89296aa805a6aa31e98503321c30d7177711a1c98a77cbf8edf2497d0f5f7593c72457b36cd3e4f19e1f3c5636c0ca7a1817d".decode("hex")
+ serverSecret = "0d5a0bb4621c4eec9ee28b0339047e7afdaae87c5f0f972253f2e90f55dbda4a16efc98cb3b925d4237a14527b1db361f460dae271f115c28ff5f3ef5fb5dae20a8ccc12bea3fc3f5911853eff3642e649140fcf0892a13ec8b22e94a750a2930c64f5792a22bc01580cbd041c7a8c21659abacead12fd4460f17b27f5940d4d".decode("hex")
+
+ # Client part
+ Y = mpin.ffi.new("octet*")
+ Yval = mpin.ffi.new("char [%s]" % len(y), y)
+ Y[0].val = Yval
+ Y[0].max = len(y)
+ Y[0].len = len(y)
+
+ MPIN_ID = mpin.ffi.new("octet*")
+ MPIN_IDval = mpin.ffi.new("char [%s]" % len(mpin_id), mpin_id)
+ MPIN_ID[0].val = MPIN_IDval
+ MPIN_ID[0].max = len(mpin_id)
+ MPIN_ID[0].len = len(mpin_id)
+
+ TOKEN = mpin.ffi.new("octet*")
+ TOKENval = mpin.ffi.new("char [%s]" % len(token), token)
+ TOKEN[0].val = TOKENval
+ TOKEN[0].len = len(token)
+ TOKEN[0].max = len(token)
+
+ X = mpin.ffi.new("octet*")
+ Xval = mpin.ffi.new("char []", mpin.PGS)
+ X[0].val = Xval
+ X[0].max = mpin.PGS
+ X[0].len = mpin.PGS
+
+ CLIENT_SECRET = mpin.ffi.new("octet*")
+ CLIENT_SECRETval = mpin.ffi.new("char []", mpin.G1)
+ CLIENT_SECRET[0].val = CLIENT_SECRETval
+ CLIENT_SECRET[0].max = mpin.G1
+ CLIENT_SECRET[0].len = mpin.G1
+
+ U = mpin.ffi.new("octet*")
+ Uval = mpin.ffi.new("char []", mpin.G1)
+ U[0].val = Uval
+ U[0].max = mpin.G1
+ U[0].len = mpin.G1
+
+ UT = mpin.ffi.new("octet*")
+ UTval = mpin.ffi.new("char []", mpin.G1)
+ UT[0].val = UTval
+ UT[0].max = mpin.G1
+ UT[0].len = mpin.G1
+
+ TIMEPERMIT = mpin.ffi.new("octet*")
+ TIMEPERMITval = mpin.ffi.new("char [%s]" % len(timePermit), timePermit)
+ TIMEPERMIT[0].val = TIMEPERMITval
+ TIMEPERMIT[0].len = len(timePermit)
+ TIMEPERMIT[0].max = len(timePermit)
+
+ # Client first pass
+ rtn = mpin.libmpin.MPIN_CLIENT_1(date, MPIN_ID, RNG, X, PIN, TOKEN, CLIENT_SECRET, U, UT, TIMEPERMIT)
+ assert rtn == 0
+
+ # Client second pass
+ rtn = mpin.libmpin.MPIN_CLIENT_2(X, Y, CLIENT_SECRET)
+ assert rtn == 0
+
+ # Server second pass
+ hid, htid = crypto.mpin_server_1(mpin_id, date)
+ success_code, _, _ = crypto.mpin_server_2(
+ serverSecret,
+ mpin.toHex(CLIENT_SECRET).decode('hex'),
+ date, hid, htid,
+ mpin.toHex(Y).decode('hex'),
+ mpin.toHex(U).decode('hex'),
+ mpin.toHex(UT).decode('hex'))
+ assert success_code == 0
+
+
+def test_encrypt_decrypt_master_secret():
+ """Test encryption/decription of master secret."""
+ seed = open('/dev/urandom', 'rb').read(100)
+
+ rng = crypto.get_random_generator(seed)
+ now = datetime.datetime.now()
+
+ aes_key = PBKDF2('passphrase', 'salt').read(16)
+
+ ciphertext_hex, iv_hex, tag_hex = crypto.aes_gcm_encrypt('master_secret', aes_key, rng, now.strftime('%Y-%m-%dT%H:%M:%SZ'))
+
+ tag, plaintext = crypto.aes_gcm_decrypt(
+ aes_key=aes_key,
+ iv=str(iv_hex.decode('hex')),
+ header=str(now.strftime('%Y-%m-%dT%H:%M:%SZ')),
+ ciphertext=str(ciphertext_hex.decode('hex')))
+
+ assert tag == tag_hex
+ assert plaintext.decode('hex') == 'master_secret'
diff --git a/tests/test_signing.py b/tests/test_signing.py
new file mode 100644
index 0000000..261cd8b
--- /dev/null
+++ b/tests/test_signing.py
@@ -0,0 +1,46 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from mpin_utils.common import signMessage, verifySignature
+
+
+def test_signing_valid():
+ message = 'Hello world!'
+ key = 'super secret'
+ expected_signature = 'f577954ea54f8e8cc1b7d5d238dde635a783a3a37a4ba44877e9f63269cd4b53'
+
+ signature = signMessage(message, key)
+
+ assert signature == expected_signature
+
+ valid, reason, code = verifySignature(message, signature, key)
+
+ assert valid
+ assert reason == 'Valid signature'
+ assert code == 200
+
+
+def test_signing_invalid():
+ message = 'Hello world!'
+ key = 'super secret'
+ signature = 'invalid signature'
+
+ valid, reason, code = verifySignature(message, signature, key)
+
+ assert not valid
+ assert reason == 'Invalid signature'
+ assert code == 401
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..c3a113b
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,23 @@
+[tox]
+envlist = py26, py27
+
+[testenv]
+commands = ./runtests.sh
+deps = -rrequirements/dev.txt
+passenv = PYTHONPATH LD_LIBRARY_PATH
+
+[pytest]
+addopts =
+ -rsxX
+ -q
+ --ff
+ --strict
+norecursedirs =
+ .tox
+pep8ignore =
+ *.py E501
+ servers/rps/rpsservice.py E402
+ servers/dta/dtaservice.py E402
+ servers/demo/demoservice.py E402
+flakes-ignore =
+ config.py ImportStarUsed