123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694 |
- ###############################################################################
- #
- # The MIT License (MIT)
- #
- # Copyright (c) typedef int GmbH
- #
- # Permission is hereby granted, free of charge, to any person obtaining a copy
- # of this software and associated documentation files (the "Software"), to deal
- # in the Software without restriction, including without limitation the rights
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- # copies of the Software, and to permit persons to whom the Software is
- # furnished to do so, subject to the following conditions:
- #
- # The above copyright notice and this permission notice shall be included in
- # all copies or substantial portions of the Software.
- #
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- # THE SOFTWARE.
- #
- ###############################################################################
-
- import os
- import base64
- import struct
- import time
- import binascii
- import hmac
- import hashlib
- import random
- from typing import Optional, Dict
-
- from autobahn.util import public
- from autobahn.util import xor as xor_array
- from autobahn.wamp.interfaces import IAuthenticator
-
- from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
- from cryptography.hazmat.primitives import hashes
- from cryptography.hazmat.backends import default_backend
-
- # if we don't have argon2/passlib (see "authentication" extra) then
- # you don't get AuthScram and variants
- try:
- from argon2.low_level import hash_secret
- from argon2 import Type
- from passlib.utils import saslprep
- HAS_ARGON = True
- except ImportError:
- HAS_ARGON = False
-
-
- __all__ = (
- 'AuthAnonymous',
- 'AuthScram',
- 'AuthCryptoSign',
- 'AuthWampCra',
- 'AuthTicket',
- 'create_authenticator',
- 'pbkdf2',
- 'generate_totp_secret',
- 'compute_totp',
- 'check_totp',
- 'qrcode_from_totp',
- 'derive_key',
- 'generate_wcs',
- 'compute_wcs',
- 'derive_scram_credential',
- )
-
-
- def create_authenticator(name, **kwargs):
- """
- Accepts various keys and values to configure an authenticator. The
- valid keys depend on the kind of authenticator but all can
- understand: `authextra`, `authid` and `authrole`
-
- :return: an instance implementing IAuthenticator with the given
- configuration.
- """
- try:
- klass = {
- AuthScram.name: AuthScram,
- AuthCryptoSign.name: AuthCryptoSign,
- AuthCryptoSignProxy.name: AuthCryptoSignProxy,
- AuthWampCra.name: AuthWampCra,
- AuthAnonymous.name: AuthAnonymous,
- AuthAnonymousProxy.name: AuthAnonymousProxy,
- AuthTicket.name: AuthTicket,
- }[name]
- except KeyError:
- raise ValueError(
- "Unknown authenticator '{}'".format(name)
- )
- # this may raise further ValueErrors if the kwargs are wrong
- authenticator = klass(**kwargs)
- return authenticator
-
-
- # experimental authentication API
- class AuthAnonymous(object):
- name = 'anonymous'
-
- def __init__(self, **kw):
- self._args = kw
-
- @property
- def authextra(self):
- return self._args.get('authextra', dict())
-
- def on_challenge(self, session, challenge):
- raise RuntimeError(
- "on_challenge called on anonymous authentication"
- )
-
- def on_welcome(self, msg, authextra):
- return None
-
-
- IAuthenticator.register(AuthAnonymous)
-
-
- class AuthAnonymousProxy(AuthAnonymous):
- name = 'anonymous-proxy'
-
-
- IAuthenticator.register(AuthAnonymousProxy)
-
-
- class AuthTicket(object):
- name = 'ticket'
-
- def __init__(self, **kw):
- self._args = kw
- try:
- self._ticket = self._args.pop('ticket')
- except KeyError:
- raise ValueError(
- "ticket authentication requires 'ticket=' kwarg"
- )
-
- @property
- def authextra(self):
- return self._args.get('authextra', dict())
-
- def on_challenge(self, session, challenge):
- assert challenge.method == "ticket"
- return self._ticket
-
- def on_welcome(self, msg, authextra):
- return None
-
-
- IAuthenticator.register(AuthTicket)
-
-
- class AuthCryptoSign(object):
- name = 'cryptosign'
-
- def __init__(self, **kw):
- # should put in checkconfig or similar
- for key in kw.keys():
- if key not in ['authextra', 'authid', 'authrole', 'privkey']:
- raise ValueError(
- "Unexpected key '{}' for {}".format(key, self.__class__.__name__)
- )
- for key in ['privkey']:
- if key not in kw:
- raise ValueError(
- "Must provide '{}' for cryptosign".format(key)
- )
-
- from autobahn.wamp.cryptosign import CryptosignKey
- self._privkey = CryptosignKey.from_bytes(
- binascii.a2b_hex(kw['privkey'])
- )
-
- if 'pubkey' in kw.get('authextra', dict()):
- pubkey = kw['authextra']['pubkey']
- if pubkey != self._privkey.public_key():
- raise ValueError(
- "Public key doesn't correspond to private key"
- )
- else:
- kw['authextra'] = kw.get('authextra', dict())
- kw['authextra']['pubkey'] = self._privkey.public_key()
-
- self._channel_binding = kw.get('authextra', dict()).get('channel_binding', None)
- self._args = kw
-
- @property
- def authextra(self):
- return self._args.get('authextra', dict())
-
- def on_challenge(self, session, challenge):
- channel_id = session._transport.transport_details.channel_id.get(self._channel_binding, None)
- return self._privkey.sign_challenge(challenge,
- channel_id=channel_id,
- channel_id_type=self._channel_binding)
-
- def on_welcome(self, msg, authextra):
- return None
-
-
- IAuthenticator.register(AuthCryptoSign)
-
-
- class AuthCryptoSignProxy(AuthCryptoSign):
- name = 'cryptosign-proxy'
-
-
- IAuthenticator.register(AuthCryptoSignProxy)
-
-
- def _hash_argon2id13_secret(password, salt, iterations, memory):
- """
- Internal helper. Returns the salted/hashed password using the
- argon2id-13 algorithm. The return value is base64-encoded.
- """
- rawhash = hash_secret(
- secret=password,
- salt=base64.b64decode(salt),
- time_cost=iterations,
- memory_cost=memory,
- parallelism=1, # hard-coded by WAMP-SCRAM spec
- hash_len=32,
- type=Type.ID,
- version=0x13, # note this is decimal "19" which appears in places
- )
- # spits out stuff like:
- # '$argon2i$v=19$m=512,t=2,p=2$5VtWOO3cGWYQHEMaYGbsfQ$AcmqasQgW/wI6wAHAMk4aQ'
-
- _, tag, ver, options, salt_data, hash_data = rawhash.split(b'$')
- return hash_data
-
-
- def _hash_pbkdf2_secret(password, salt, iterations):
- """
- Internal helper for SCRAM authentication
- """
- return pbkdf2(password, salt, iterations, keylen=32)
-
-
- class AuthScram(object):
- """
- Implements "wamp-scram" authentication for components.
-
- NOTE: This is a prototype of a draft spec; see
- https://github.com/wamp-proto/wamp-proto/issues/135
- """
- name = 'scram'
-
- def __init__(self, **kw):
- if not HAS_ARGON:
- raise RuntimeError(
- "Cannot support WAMP-SCRAM without argon2_cffi and "
- "passlib libraries; install autobahn['scram']"
- )
- self._args = kw
- self._client_nonce = None
-
- @property
- def authextra(self):
- # is authextra() called exactly once per authentication?
- if self._client_nonce is None:
- self._client_nonce = base64.b64encode(os.urandom(16)).decode('ascii')
- return {
- "nonce": self._client_nonce,
- }
-
- def on_challenge(self, session, challenge):
- assert challenge.method == "scram"
- assert self._client_nonce is not None
- required_args = ['nonce', 'kdf', 'salt', 'iterations']
- optional_args = ['memory', 'channel_binding']
- for k in required_args:
- if k not in challenge.extra:
- raise RuntimeError(
- "WAMP-SCRAM challenge option '{}' is "
- " required but not specified".format(k)
- )
- for k in challenge.extra:
- if k not in optional_args + required_args:
- raise RuntimeError(
- "WAMP-SCRAM challenge has unknown attribute '{}'".format(k)
- )
-
- channel_binding = challenge.extra.get('channel_binding', '')
- server_nonce = challenge.extra['nonce'] # base64
- salt = challenge.extra['salt'] # base64
- iterations = int(challenge.extra['iterations'])
- memory = int(challenge.extra.get('memory', -1))
- password = self._args['password'].encode('utf8') # supplied by user
- authid = saslprep(self._args['authid'])
- algorithm = challenge.extra['kdf']
- client_nonce = self._client_nonce
-
- self._auth_message = (
- "{client_first_bare},{server_first},{client_final_no_proof}".format(
- client_first_bare="n={},r={}".format(authid, client_nonce),
- server_first="r={},s={},i={}".format(server_nonce, salt, iterations),
- client_final_no_proof="c={},r={}".format(channel_binding, server_nonce),
- )
- ).encode('ascii')
-
- if algorithm == 'argon2id-13':
- if memory == -1:
- raise ValueError(
- "WAMP-SCRAM 'argon2id-13' challenge requires 'memory' parameter"
- )
- self._salted_password = _hash_argon2id13_secret(password, salt, iterations, memory)
- elif algorithm == 'pbkdf2':
- self._salted_password = _hash_pbkdf2_secret(password, salt, iterations)
- else:
- raise RuntimeError(
- "WAMP-SCRAM specified unknown KDF '{}'".format(algorithm)
- )
-
- client_key = hmac.new(self._salted_password, b"Client Key", hashlib.sha256).digest()
- stored_key = hashlib.new('sha256', client_key).digest()
-
- client_signature = hmac.new(stored_key, self._auth_message, hashlib.sha256).digest()
- client_proof = xor_array(client_key, client_signature)
-
- return base64.b64encode(client_proof)
-
- def on_welcome(self, session, authextra):
- """
- When the server is satisfied, it sends a 'WELCOME' message.
-
- This hook allows us an opportunity to deny the session right
- before it gets set up -- we check the server-signature thus
- authorizing the server and if it fails we drop the connection.
- """
- alleged_server_sig = base64.b64decode(authextra['scram_server_signature'])
- server_key = hmac.new(self._salted_password, b"Server Key", hashlib.sha256).digest()
- server_signature = hmac.new(server_key, self._auth_message, hashlib.sha256).digest()
- if not hmac.compare_digest(server_signature, alleged_server_sig):
- session.log.error("Verification of server SCRAM signature failed")
- return "Verification of server SCRAM signature failed"
- session.log.info(
- "Verification of server SCRAM signature successful"
- )
- return None
-
-
- IAuthenticator.register(AuthScram)
-
-
- class AuthWampCra(object):
- name = 'wampcra'
-
- def __init__(self, **kw):
- # should put in checkconfig or similar
- for key in kw.keys():
- if key not in ['authextra', 'authid', 'authrole', 'secret']:
- raise ValueError(
- "Unexpected key '{}' for {}".format(key, self.__class__.__name__)
- )
- for key in ['secret', 'authid']:
- if key not in kw:
- raise ValueError(
- "Must provide '{}' for wampcra".format(key)
- )
-
- self._args = kw
- self._secret = kw.pop('secret')
- if not isinstance(self._secret, str):
- self._secret = self._secret.decode('utf8')
-
- @property
- def authextra(self):
- return self._args.get('authextra', dict())
-
- def on_challenge(self, session, challenge):
- key = self._secret.encode('utf8')
- if 'salt' in challenge.extra:
- key = derive_key(
- key,
- challenge.extra['salt'],
- challenge.extra['iterations'],
- challenge.extra['keylen']
- )
-
- signature = compute_wcs(
- key,
- challenge.extra['challenge'].encode('utf8')
- )
- return signature.decode('ascii')
-
- def on_welcome(self, msg, authextra):
- return None
-
-
- IAuthenticator.register(AuthWampCra)
-
-
- @public
- def generate_totp_secret(length=10):
- """
- Generates a new Base32 encoded, random secret.
-
- .. seealso:: http://en.wikipedia.org/wiki/Base32
-
- :param length: The length of the entropy used to generate the secret.
- :type length: int
-
- :returns: The generated secret in Base32 (letters ``A-Z`` and digits ``2-7``).
- The length of the generated secret is ``length * 8 / 5`` octets.
- :rtype: unicode
- """
- assert(type(length) == int)
- return base64.b32encode(os.urandom(length)).decode('ascii')
-
-
- @public
- def compute_totp(secret, offset=0):
- """
- Computes the current TOTP code.
-
- :param secret: Base32 encoded secret.
- :type secret: unicode
- :param offset: Time offset (in steps, use eg -1, 0, +1 for compliance with RFC6238)
- for which to compute TOTP.
- :type offset: int
-
- :returns: TOTP for current time (+/- offset).
- :rtype: unicode
- """
- assert(type(secret) == str)
- assert(type(offset) == int)
- try:
- key = base64.b32decode(secret)
- except TypeError:
- raise Exception('invalid secret')
- interval = offset + int(time.time()) // 30
- msg = struct.pack('>Q', interval)
- digest = hmac.new(key, msg, hashlib.sha1).digest()
- o = 15 & (digest[19])
- token = (struct.unpack('>I', digest[o:o + 4])[0] & 0x7fffffff) % 1000000
- return '{0:06d}'.format(token)
-
-
- @public
- def check_totp(secret, ticket):
- """
- Check a TOTP value received from a principal trying to authenticate against
- the expected value computed from the secret shared between the principal and
- the authenticating entity.
-
- The Internet can be slow, and clocks might not match exactly, so some
- leniency is allowed. RFC6238 recommends looking an extra time step in either
- direction, which essentially opens the window from 30 seconds to 90 seconds.
-
- :param secret: The secret shared between the principal (eg a client) that
- is authenticating, and the authenticating entity (eg a server).
- :type secret: unicode
- :param ticket: The TOTP value to be checked.
- :type ticket: unicode
-
- :returns: ``True`` if the TOTP value is correct, else ``False``.
- :rtype: bool
- """
- for offset in [0, 1, -1]:
- if ticket == compute_totp(secret, offset):
- return True
- return False
-
-
- @public
- def qrcode_from_totp(secret, label, issuer):
- if type(secret) != str:
- raise Exception('secret must be of type unicode, not {}'.format(type(secret)))
-
- if type(label) != str:
- raise Exception('label must be of type unicode, not {}'.format(type(label)))
-
- try:
- import qrcode
- import qrcode.image.svg
- except ImportError:
- raise Exception('qrcode not installed')
-
- return qrcode.make(
- 'otpauth://totp/{}?secret={}&issuer={}'.format(label, secret, issuer),
- box_size=3,
- image_factory=qrcode.image.svg.SvgImage).to_string()
-
-
- @public
- def pbkdf2(data, salt, iterations=1000, keylen=32, hashfunc=None):
- """
- Returns a binary digest for the PBKDF2 hash algorithm of ``data``
- with the given ``salt``. It iterates ``iterations`` time and produces a
- key of ``keylen`` bytes. By default SHA-256 is used as hash function,
- a different hashlib ``hashfunc`` can be provided.
-
- :param data: The data for which to compute the PBKDF2 derived key.
- :type data: bytes
- :param salt: The salt to use for deriving the key.
- :type salt: bytes
- :param iterations: The number of iterations to perform in PBKDF2.
- :type iterations: int
- :param keylen: The length of the cryptographic key to derive.
- :type keylen: int
- :param hashfunc: Name of the hash algorithm to use
- :type hashfunc: str
-
- :returns: The derived cryptographic key.
- :rtype: bytes
- """
- if not (type(data) == bytes) or \
- not (type(salt) == bytes) or \
- not (type(iterations) == int) or \
- not (type(keylen) == int):
- raise ValueError("Invalid argument types")
-
- # justification: WAMP-CRA uses SHA256 and users shouldn't have any
- # other reason to call this particular pbkdf2 function (arguably,
- # it should be private maybe?)
- if hashfunc is None:
- hashfunc = 'sha256'
- if hashfunc is callable:
- # used to take stuff from hashlib; translate?
- raise ValueError(
- "pbkdf2 now takes the name of a hash algorithm for 'hashfunc='"
- )
-
- backend = default_backend()
-
- # https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#pbkdf2
- kdf = PBKDF2HMAC(
- algorithm=getattr(hashes, hashfunc.upper())(),
- length=keylen,
- salt=salt,
- iterations=iterations,
- backend=backend,
- )
- return kdf.derive(data)
-
-
- @public
- def derive_key(secret, salt, iterations=1000, keylen=32):
- """
- Computes a derived cryptographic key from a password according to PBKDF2.
-
- .. seealso:: http://en.wikipedia.org/wiki/PBKDF2
-
- :param secret: The secret.
- :type secret: bytes or unicode
- :param salt: The salt to be used.
- :type salt: bytes or unicode
- :param iterations: Number of iterations of derivation algorithm to run.
- :type iterations: int
- :param keylen: Length of the key to derive in bytes.
- :type keylen: int
-
- :return: The derived key in Base64 encoding.
- :rtype: bytes
- """
- if not (type(secret) in [str, bytes]):
- raise ValueError("'secret' must be bytes")
- if not (type(salt) in [str, bytes]):
- raise ValueError("'salt' must be bytes")
- if not (type(iterations) == int):
- raise ValueError("'iterations' must be an integer")
- if not (type(keylen) == int):
- raise ValueError("'keylen' must be an integer")
- if type(secret) == str:
- secret = secret.encode('utf8')
- if type(salt) == str:
- salt = salt.encode('utf8')
- key = pbkdf2(secret, salt, iterations, keylen)
- return binascii.b2a_base64(key).strip()
-
-
- WCS_SECRET_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
- """
- The characters from which :func:`autobahn.wamp.auth.generate_wcs` generates secrets.
- """
-
-
- @public
- def generate_wcs(length=14):
- """
- Generates a new random secret for use with WAMP-CRA.
-
- The secret generated is a random character sequence drawn from
-
- - upper and lower case latin letters
- - digits
- -
-
- :param length: The length of the secret to generate.
- :type length: int
-
- :return: The generated secret. The length of the generated is ``length`` octets.
- :rtype: bytes
- """
- assert(type(length) == int)
- return "".join(random.choice(WCS_SECRET_CHARSET) for _ in range(length)).encode('ascii')
-
-
- @public
- def compute_wcs(key, challenge):
- """
- Compute an WAMP-CRA authentication signature from an authentication
- challenge and a (derived) key.
-
- :param key: The key derived (via PBKDF2) from the secret.
- :type key: bytes
- :param challenge: The authentication challenge to sign.
- :type challenge: bytes
-
- :return: The authentication signature.
- :rtype: bytes
- """
- assert(type(key) in [str, bytes])
- assert(type(challenge) in [str, bytes])
- if type(key) == str:
- key = key.encode('utf8')
- if type(challenge) == str:
- challenge = challenge.encode('utf8')
- sig = hmac.new(key, challenge, hashlib.sha256).digest()
- return binascii.b2a_base64(sig).strip()
-
-
- def derive_scram_credential(email: str, password: str, salt: Optional[bytes] = None) -> Dict:
- """
- Derive WAMP-SCRAM credentials from user email and password. The SCRAM parameters used
- are the following (these are also contained in the returned credentials):
-
- * kdf ``argon2id-13``
- * time cost ``4096``
- * memory cost ``512``
- * parallelism ``1``
-
- See `draft-irtf-cfrg-argon2 <https://datatracker.ietf.org/doc/draft-irtf-cfrg-argon2/>`__ and
- `argon2-cffi <https://argon2-cffi.readthedocs.io/en/stable/>`__.
-
- :param email: User email.
- :param password: User password.
- :param salt: Optional salt to use (must be 16 bytes long). If none is given, compute salt
- from email as ``salt = SHA256(email)[:16]``.
- :return: WAMP-SCRAM credentials. When serialized, the returned credentials can be copy-pasted
- into the ``config.json`` node configuration for a Crossbar.io node.
- """
- assert HAS_ARGON, 'missing dependency argon2'
- from argon2.low_level import hash_secret
- from argon2.low_level import Type
-
- # derive salt from email
- if not salt:
- m = hashlib.sha256()
- m.update(email.encode('utf8'))
- salt = m.digest()[:16]
- assert len(salt) == 16
-
- hash_data = hash_secret(
- secret=password.encode('utf8'),
- salt=salt,
- time_cost=4096,
- memory_cost=512,
- parallelism=1,
- hash_len=32,
- type=Type.ID,
- version=19,
- )
- _, tag, v, params, _, salted_password = hash_data.decode('ascii').split('$')
- assert tag == 'argon2id'
- assert v == 'v=19' # argon's version 1.3 is represented as 0x13, which is 19 decimal...
- params = {
- k: v
- for k, v in
- [x.split('=') for x in params.split(',')]
- }
-
- salted_password = salted_password.encode('ascii')
- client_key = hmac.new(salted_password, b"Client Key", hashlib.sha256).digest()
- stored_key = hashlib.new('sha256', client_key).digest()
- server_key = hmac.new(salted_password, b"Server Key", hashlib.sha256).digest()
-
- credential = {
- "kdf": "argon2id-13",
- "memory": int(params['m']),
- "iterations": int(params['t']),
- "salt": binascii.b2a_hex(salt).decode('ascii'),
- "stored-key": binascii.b2a_hex(stored_key).decode('ascii'),
- "server-key": binascii.b2a_hex(server_key).decode('ascii'),
- }
- return credential
|