Ejemplo n.º 1
0
Archivo: core.py Proyecto: phizaz/gaw
def _verify(b, secret, signature):
    alg = HMACAlgorithm(HMACAlgorithm.SHA256)
    key = alg.prepare_key(secret)
    if not alg.verify(b, key, signature):
        raise PostofficeException(name='DecodeError',
                                  message='signature verification failed')
    return True
Ejemplo n.º 2
0
Archivo: core.py Proyecto: phizaz/gaw
def _verify(b, secret, signature):
    alg = HMACAlgorithm(HMACAlgorithm.SHA256)
    key = alg.prepare_key(secret)
    if not alg.verify(b, key, signature):
        raise PostofficeException(name='DecodeError',
                                  message='signature verification failed')
    return True
Ejemplo n.º 3
0
    def test_hmac_jwk_should_parse_and_verify(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with open(key_path("jwk_hmac.json")) as keyfile:
            key = algo.from_jwk(keyfile.read())

        signature = algo.sign(b"Hello World!", key)
        assert algo.verify(b"Hello World!", key, signature)
Ejemplo n.º 4
0
    def test_hmac_should_reject_nonstring_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with self.assertRaises(TypeError) as context:
            algo.prepare_key(object())

        exception = context.exception
        self.assertEqual(str(exception), 'Expecting a string- or bytes-formatted key.')
Ejemplo n.º 5
0
    def test_hmac_should_reject_nonstring_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with pytest.raises(TypeError) as context:
            algo.prepare_key(object())

        exception = context.value
        assert str(exception) == 'Expecting a string- or bytes-formatted key.'
Ejemplo n.º 6
0
    def test_hmac_should_reject_nonstring_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with pytest.raises(TypeError) as context:
            algo.prepare_key(object())

        exception = context.value
        assert str(exception) == 'Expecting a string- or bytes-formatted key.'
Ejemplo n.º 7
0
    def test_hmac_verify_should_return_true_for_test_vector(self):
        signing_input = ensure_bytes(
            'eyJhbGciOiJIUzI1NiIsImtpZCI6IjAxOGMwYWU1LTRkOWItNDcxYi1iZmQ2LWVlZ'
            'jMxNGJjNzAzNyJ9.SXTigJlzIGEgZGFuZ2Vyb3VzIGJ1c2luZXNzLCBGcm9kbywgZ'
            '29pbmcgb3V0IHlvdXIgZG9vci4gWW91IHN0ZXAgb250byB0aGUgcm9hZCwgYW5kIG'
            'lmIHlvdSBkb24ndCBrZWVwIHlvdXIgZmVldCwgdGhlcmXigJlzIG5vIGtub3dpbmc'
            'gd2hlcmUgeW91IG1pZ2h0IGJlIHN3ZXB0IG9mZiB0by4'
        )

        signature = base64url_decode(ensure_bytes(
            's0h6KThzkfBBBkLspW1h84VsJZFTsPPqMDA7g1Md7p0'
        ))

        algo = HMACAlgorithm(HMACAlgorithm.SHA256)
        key = algo.prepare_key(load_hmac_key())

        result = algo.verify(signing_input, key, signature)
        assert result
Ejemplo n.º 8
0
    def test_hmac_verify_should_return_true_for_test_vector(self):
        """
        This test verifies that HMAC verification works with a known good
        signature and key.

        Reference: https://tools.ietf.org/html/rfc7520#section-4.4
        """
        signing_input = ensure_bytes(
            'eyJhbGciOiJIUzI1NiIsImtpZCI6IjAxOGMwYWU1LTRkOWItNDcxYi1iZmQ2LWVlZ'
            'jMxNGJjNzAzNyJ9.SXTigJlzIGEgZGFuZ2Vyb3VzIGJ1c2luZXNzLCBGcm9kbywgZ'
            '29pbmcgb3V0IHlvdXIgZG9vci4gWW91IHN0ZXAgb250byB0aGUgcm9hZCwgYW5kIG'
            'lmIHlvdSBkb24ndCBrZWVwIHlvdXIgZmVldCwgdGhlcmXigJlzIG5vIGtub3dpbmc'
            'gd2hlcmUgeW91IG1pZ2h0IGJlIHN3ZXB0IG9mZiB0by4')

        signature = base64url_decode(
            ensure_bytes('s0h6KThzkfBBBkLspW1h84VsJZFTsPPqMDA7g1Md7p0'))

        algo = HMACAlgorithm(HMACAlgorithm.SHA256)
        key = algo.prepare_key(load_hmac_key())

        result = algo.verify(signing_input, key, signature)
        assert result
class ShortClientTokenTool(object):

    ALGORITHM = 'HS256'
    signer = HMACAlgorithm(HMACAlgorithm.SHA256)

    @classmethod
    def adobe_base64_encode(cls, to_encode):
        """A modified base64 encoding that avoids triggering an Adobe bug.

        The bug seems to happen when the 'password' portion of a
        username/password pair contains a + character. So we replace +
        with :. We also replace / (another "suspicious" character)
        with ;. and strip newlines.
        """
        if isinstance(to_encode, unicode):
            to_encode = to_encode.encode("utf8")
        encoded = base64.encodestring(to_encode)
        return encoded.replace(b"+", b":").replace(b"/",
                                                   b";").replace(b"=",
                                                                 b"@").strip()

    @classmethod
    def adobe_base64_decode(cls, to_decode):
        """Undoes adobe_base64_encode."""
        if isinstance(to_decode, unicode):
            to_decode = to_decode.encode("utf8")
        to_decode = to_decode.replace(b":",
                                      b"+").replace(b";",
                                                    b"/").replace(b"@", b"=")
        return base64.decodestring(to_decode)

    # The JWT spec takes January 1 1970 as the epoch.
    JWT_EPOCH = datetime.datetime(1970, 1, 1)

    # For the sake of shortening tokens, the Short Client Token spec
    # takes January 1 2017 as the epoch, and measures time in minutes
    # rather than seconds.
    SCT_EPOCH = datetime.datetime(2017, 1, 1)

    @classmethod
    def sct_numericdate(cls, d):
        """Turn a datetime object into a number of minutes since the epoch, as
        per the Short Client Token spec.
        """
        return (d - cls.SCT_EPOCH).total_seconds() / 60

    @classmethod
    def jwt_numericdate(cls, d):
        """Turn a datetime object into a NumericDate as per RFC 7519."""
        return (d - cls.JWT_EPOCH).total_seconds()
Ejemplo n.º 10
0
    def test_hmac_should_throw_exception_if_key_is_ssh_public_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with pytest.raises(InvalidKeyError):
            with open(key_path('testkey_rsa.pub'), 'r') as keyfile:
                algo.prepare_key(keyfile.read())
Ejemplo n.º 11
0
class AuthdataUtility(object):
    """Generate authdata JWTs as per the Vendor ID Service spec:
    https://docs.google.com/document/d/1j8nWPVmy95pJ_iU4UTC-QgHK2QhDUSdQ0OQTFR2NE_0

    Capable of encoding JWTs (for this library), and decoding them
    (from this library and potentially others).

    Also generates and decodes JWT-like strings used to get around
    Adobe's lack of support for authdata in deactivation.
    """

    # The type of the Credential created to identify a patron to the
    # Vendor ID Service. Using this as an alias keeps the Vendor ID
    # Service from knowing anything about the patron's true
    # identity. This Credential is permanent (unlike a patron's
    # username or authorization identifier), but can be revoked (if
    # the patron needs to reset their Adobe account ID) with no
    # consequences other than losing their currently checked-in books.
    ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER = "Identifier for Adobe account ID purposes"

    ALGORITHM = 'HS256'

    def __init__(self,
                 vendor_id,
                 library_uri,
                 library_short_name,
                 secret,
                 other_libraries={}):
        """Basic constructor.

        :param vendor_id: The Adobe Vendor ID that should accompany authdata
        generated by this utility.

        If this library has its own Adobe Vendor ID, it should go
        here. If this library is delegating authdata control to some
        other library, that library's Vendor ID should go here.

        :param library_uri: A URI identifying this library. This is
        used when generating JWTs.

        :param short_name: A short string identifying this
        library. This is used when generating short client tokens,
        which must be as short as possible (thus the name).

        :param secret: A secret used to sign this library's authdata.

        :param other_libraries: A dictionary mapping other libraries'
        canonical URIs to their (short name, secret) 2-tuples. An
        instance of this class will be able to decode an authdata from
        any library in this dictionary (plus the library it was
        initialized for).
        """
        self.vendor_id = vendor_id

        # This is used to _encode_ JWTs and send them to the
        # delegation authority.
        self.library_uri = library_uri

        # This is used to _encode_ short client tokens.
        self.short_name = library_short_name.upper()

        # This is used to encode both JWTs and short client tokens.
        self.secret = secret

        # This is used by the delegation authority to _decode_ JWTs.
        self.secrets_by_library_uri = {}
        self.secrets_by_library_uri[self.library_uri] = secret

        # This is used by the delegation authority to _decode_ short
        # client tokens.
        self.library_uris_by_short_name = {}
        self.library_uris_by_short_name[self.short_name] = self.library_uri

        # Fill in secrets_by_library_uri and library_uris_by_short_name
        # for other libraries.
        for uri, v in other_libraries.items():
            short_name, secret = v
            short_name = short_name.upper()
            if short_name in self.library_uris_by_short_name:
                # This can happen if the same library is in the list
                # twice, capitalized differently.
                raise ValueError("Duplicate short name: %s" % short_name)
            self.library_uris_by_short_name[short_name] = uri
            self.secrets_by_library_uri[uri] = secret

        self.log = logging.getLogger("Adobe authdata utility")

        self.short_token_signer = HMACAlgorithm(HMACAlgorithm.SHA256)
        self.short_token_signing_key = self.short_token_signer.prepare_key(
            self.secret)

    VENDOR_ID_KEY = u'vendor_id'
    OTHER_LIBRARIES_KEY = u'other_libraries'

    @classmethod
    def from_config(cls, library, _db=None):
        """Initialize an AuthdataUtility from site configuration.

        :return: An AuthdataUtility if one is configured; otherwise None.

        :raise CannotLoadConfiguration: If an AuthdataUtility is
            incompletely configured.
        """
        _db = _db or Session.object_session(library)
        if not _db:
            raise ValueError(
                "No database connection provided and could not derive one from Library object!"
            )
        # Use a version of the library
        library = _db.merge(library, load=False)

        # Try to find an external integration with a configured Vendor ID.
        integrations = _db.query(ExternalIntegration).outerjoin(
            ExternalIntegration.libraries).filter(
                ExternalIntegration.protocol ==
                ExternalIntegration.OPDS_REGISTRATION,
                ExternalIntegration.goal == ExternalIntegration.DISCOVERY_GOAL,
                Library.id == library.id)

        integration = None
        for possible_integration in integrations:
            vendor_id = ConfigurationSetting.for_externalintegration(
                cls.VENDOR_ID_KEY, possible_integration).value
            if vendor_id:
                integration = possible_integration
                break

        library_uri = ConfigurationSetting.for_library(
            Configuration.WEBSITE_URL, library).value

        if not integration:
            return None

        vendor_id = integration.setting(cls.VENDOR_ID_KEY).value
        library_short_name = ConfigurationSetting.for_library_and_externalintegration(
            _db, ExternalIntegration.USERNAME, library, integration).value
        secret = ConfigurationSetting.for_library_and_externalintegration(
            _db, ExternalIntegration.PASSWORD, library, integration).value

        other_libraries = None
        adobe_integration = ExternalIntegration.lookup(
            _db,
            ExternalIntegration.ADOBE_VENDOR_ID,
            ExternalIntegration.DRM_GOAL,
            library=library)
        if adobe_integration:
            other_libraries = adobe_integration.setting(
                cls.OTHER_LIBRARIES_KEY).json_value
        other_libraries = other_libraries or dict()

        if (not vendor_id or not library_uri or not library_short_name
                or not secret):
            raise CannotLoadConfiguration(
                "Short Client Token configuration is incomplete. "
                "vendor_id, username, password and "
                "Library website_url must all be defined.")
        if '|' in library_short_name:
            raise CannotLoadConfiguration(
                "Library short name cannot contain the pipe character.")
        return cls(vendor_id, library_uri, library_short_name, secret,
                   other_libraries)

    @classmethod
    def adobe_relevant_credentials(self, patron):
        """Find all Adobe-relevant Credential objects for the given
        patron.

        This includes the patron's identifier for Adobe ID purposes,
        and (less likely) any Adobe IDs directly associated with the
        Patron.

        :return: A SQLAlchemy query
        """
        _db = Session.object_session(patron)
        types = (AdobeVendorIDModel.VENDOR_ID_UUID_TOKEN_TYPE,
                 AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER)
        return _db.query(Credential).filter(
            Credential.patron == patron).filter(Credential.type.in_(types))

    def encode(self, patron_identifier):
        """Generate an authdata JWT suitable for putting in an OPDS feed, where
        it can be picked up by a client and sent to the delegation
        authority to look up an Adobe ID.

        :return: A 2-tuple (vendor ID, authdata)
        """
        if not patron_identifier:
            raise ValueError("No patron identifier specified")
        now = datetime.datetime.utcnow()
        expires = now + datetime.timedelta(minutes=60)
        authdata = self._encode(self.library_uri, patron_identifier, now,
                                expires)
        return self.vendor_id, authdata

    def _encode(self, iss=None, sub=None, iat=None, exp=None):
        """Helper method split out separately for use in tests."""
        payload = dict(iss=iss)  # Issuer
        if sub:
            payload['sub'] = sub  # Subject
        if iat:
            payload['iat'] = self.numericdate(iat)  # Issued At
        if exp:
            payload['exp'] = self.numericdate(exp)  # Expiration Time
        return base64.encodestring(
            jwt.encode(payload, self.secret, algorithm=self.ALGORITHM))

    @classmethod
    def adobe_base64_encode(cls, str):
        """A modified base64 encoding that avoids triggering an Adobe bug.

        The bug seems to happen when the 'password' portion of a
        username/password pair contains a + character. So we replace +
        with :. We also replace / (another "suspicious" character)
        with ;. and strip newlines.
        """
        encoded = base64.encodestring(str)
        return encoded.replace("+", ":").replace("/",
                                                 ";").replace("=",
                                                              "@").strip()

    @classmethod
    def adobe_base64_decode(cls, str):
        """Undoes adobe_base64_encode."""
        encoded = str.replace(":", "+").replace(";", "/").replace("@", "=")
        return base64.decodestring(encoded)

    def decode(self, authdata):
        """Decode and verify an authdata JWT from one of the libraries managed
        by `secrets_by_library`.

        :return: a 2-tuple (library_uri, patron_identifier)

        :raise jwt.exceptions.DecodeError: When the JWT is not valid
            for any reason.
        """

        self.log.info("Authdata.decode() received authdata %s", authdata)
        # We are going to try to verify the authdata as is (in case
        # Adobe secretly decoded it en route), but we're also going to
        # try to decode it ourselves and verify it that way.
        potential_tokens = [authdata]
        try:
            decoded = base64.decodestring(authdata)
            potential_tokens.append(decoded)
        except Exception, e:
            # Do nothing -- the authdata was not encoded to begin with.
            pass

        exceptions = []
        library_uri = subject = None
        for authdata in potential_tokens:
            try:
                return self._decode(authdata)
            except Exception, e:
                self.log.error("Error decoding %s", authdata, exc_info=e)
                exceptions.append(e)
Ejemplo n.º 12
0
    def __init__(self,
                 vendor_id,
                 library_uri,
                 library_short_name,
                 secret,
                 other_libraries={}):
        """Basic constructor.

        :param vendor_id: The Adobe Vendor ID that should accompany authdata
        generated by this utility.

        If this library has its own Adobe Vendor ID, it should go
        here. If this library is delegating authdata control to some
        other library, that library's Vendor ID should go here.

        :param library_uri: A URI identifying this library. This is
        used when generating JWTs.

        :param short_name: A short string identifying this
        library. This is used when generating short client tokens,
        which must be as short as possible (thus the name).

        :param secret: A secret used to sign this library's authdata.

        :param other_libraries: A dictionary mapping other libraries'
        canonical URIs to their (short name, secret) 2-tuples. An
        instance of this class will be able to decode an authdata from
        any library in this dictionary (plus the library it was
        initialized for).
        """
        self.vendor_id = vendor_id

        # This is used to _encode_ JWTs and send them to the
        # delegation authority.
        self.library_uri = library_uri

        # This is used to _encode_ short client tokens.
        self.short_name = library_short_name.upper()

        # This is used to encode both JWTs and short client tokens.
        self.secret = secret

        # This is used by the delegation authority to _decode_ JWTs.
        self.secrets_by_library_uri = {}
        self.secrets_by_library_uri[self.library_uri] = secret

        # This is used by the delegation authority to _decode_ short
        # client tokens.
        self.library_uris_by_short_name = {}
        self.library_uris_by_short_name[self.short_name] = self.library_uri

        # Fill in secrets_by_library_uri and library_uris_by_short_name
        # for other libraries.
        for uri, v in other_libraries.items():
            short_name, secret = v
            short_name = short_name.upper()
            if short_name in self.library_uris_by_short_name:
                # This can happen if the same library is in the list
                # twice, capitalized differently.
                raise ValueError("Duplicate short name: %s" % short_name)
            self.library_uris_by_short_name[short_name] = uri
            self.secrets_by_library_uri[uri] = secret

        self.log = logging.getLogger("Adobe authdata utility")

        self.short_token_signer = HMACAlgorithm(HMACAlgorithm.SHA256)
        self.short_token_signing_key = self.short_token_signer.prepare_key(
            self.secret)
Ejemplo n.º 13
0
    def test_hmac_should_throw_exception_if_key_is_ssh_public_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with self.assertRaises(InvalidKeyError):
            with open(key_path('testkey_rsa.pub'), 'r') as keyfile:
                algo.prepare_key(keyfile.read())
Ejemplo n.º 14
0
    def test_hmac_should_throw_exception_if_key_is_x509_cert(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with self.assertRaises(InvalidKeyError):
            with open(key_path('testkey2_rsa.pub.pem'), 'r') as keyfile:
                algo.prepare_key(keyfile.read())
Ejemplo n.º 15
0
class AuthdataUtility(object):
    """Generate authdata JWTs as per the Vendor ID Service spec:
    https://docs.google.com/document/d/1j8nWPVmy95pJ_iU4UTC-QgHK2QhDUSdQ0OQTFR2NE_0

    Capable of encoding JWTs (for this library), and decoding them
    (from this library and potentially others).

    Also generates and decodes JWT-like strings used to get around
    Adobe's lack of support for authdata in deactivation.
    """

    # The type of the Credential created to identify a patron to the
    # Vendor ID Service. Using this as an alias keeps the Vendor ID
    # Service from knowing anything about the patron's true
    # identity. This Credential is permanent (unlike a patron's
    # username or authorization identifier), but can be revoked (if
    # the patron needs to reset their Adobe account ID) with no
    # consequences other than losing their currently checked-in books.
    ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER = "Identifier for Adobe account ID purposes"

    ALGORITHM = "HS256"

    def __init__(self,
                 vendor_id,
                 library_uri,
                 library_short_name,
                 secret,
                 other_libraries={}):
        """Basic constructor.

        :param vendor_id: The Adobe Vendor ID that should accompany authdata
        generated by this utility.

        If this library has its own Adobe Vendor ID, it should go
        here. If this library is delegating authdata control to some
        other library, that library's Vendor ID should go here.

        :param library_uri: A URI identifying this library. This is
        used when generating JWTs.

        :param short_name: A short string identifying this
        library. This is used when generating short client tokens,
        which must be as short as possible (thus the name).

        :param secret: A secret used to sign this library's authdata.

        :param other_libraries: A dictionary mapping other libraries'
        canonical URIs to their (short name, secret) 2-tuples. An
        instance of this class will be able to decode an authdata from
        any library in this dictionary (plus the library it was
        initialized for).
        """
        self.vendor_id = vendor_id

        # This is used to _encode_ JWTs and send them to the
        # delegation authority.
        self.library_uri = library_uri

        # This is used to _encode_ short client tokens.
        self.short_name = library_short_name.upper()

        # This is used to encode both JWTs and short client tokens.
        self.secret = secret

        # This is used by the delegation authority to _decode_ JWTs.
        self.secrets_by_library_uri = {}
        self.secrets_by_library_uri[self.library_uri] = secret

        # This is used by the delegation authority to _decode_ short
        # client tokens.
        self.library_uris_by_short_name = {}
        self.library_uris_by_short_name[self.short_name] = self.library_uri

        # Fill in secrets_by_library_uri and library_uris_by_short_name
        # for other libraries.
        for uri, v in list(other_libraries.items()):
            short_name, secret = v
            short_name = short_name.upper()
            if short_name in self.library_uris_by_short_name:
                # This can happen if the same library is in the list
                # twice, capitalized differently.
                raise ValueError("Duplicate short name: %s" % short_name)
            self.library_uris_by_short_name[short_name] = uri
            self.secrets_by_library_uri[uri] = secret

        self.log = logging.getLogger("Adobe authdata utility")

        self.short_token_signer = HMACAlgorithm(HMACAlgorithm.SHA256)
        self.short_token_signing_key = self.short_token_signer.prepare_key(
            self.secret)

    VENDOR_ID_KEY = "vendor_id"
    OTHER_LIBRARIES_KEY = "other_libraries"

    @classmethod
    def from_config(cls, library: Library, _db=None):
        """Initialize an AuthdataUtility from site configuration.

        The library must be successfully registered with a discovery
        integration in order for that integration to be a candidate
        to provide configuration for the AuthdataUtility.

        :return: An AuthdataUtility if one is configured; otherwise None.

        :raise CannotLoadConfiguration: If an AuthdataUtility is
            incompletely configured.
        """
        _db = _db or Session.object_session(library)
        if not _db:
            raise ValueError(
                "No database connection provided and could not derive one from Library object!"
            )
        # Use a version of the library
        library = _db.merge(library, load=False)

        # Try to find an external integration with a configured Vendor ID.
        integrations = (_db.query(ExternalIntegration).outerjoin(
            ExternalIntegration.libraries).filter(
                ExternalIntegration.protocol ==
                ExternalIntegration.OPDS_REGISTRATION,
                ExternalIntegration.goal == ExternalIntegration.DISCOVERY_GOAL,
                Library.id == library.id,
            ))

        for possible_integration in integrations:
            vendor_id = ConfigurationSetting.for_externalintegration(
                cls.VENDOR_ID_KEY, possible_integration).value
            registration_status = (
                ConfigurationSetting.for_library_and_externalintegration(
                    _db,
                    RegistrationConstants.LIBRARY_REGISTRATION_STATUS,
                    library,
                    possible_integration,
                ).value)
            if (vendor_id and registration_status
                    == RegistrationConstants.SUCCESS_STATUS):
                integration = possible_integration
                break
        else:
            return None

        library_uri = ConfigurationSetting.for_library(
            Configuration.WEBSITE_URL, library).value

        vendor_id = integration.setting(cls.VENDOR_ID_KEY).value
        library_short_name = ConfigurationSetting.for_library_and_externalintegration(
            _db, ExternalIntegration.USERNAME, library, integration).value
        secret = ConfigurationSetting.for_library_and_externalintegration(
            _db, ExternalIntegration.PASSWORD, library, integration).value

        other_libraries = None
        adobe_integration = ExternalIntegration.lookup(
            _db,
            ExternalIntegration.ADOBE_VENDOR_ID,
            ExternalIntegration.DRM_GOAL,
            library=library,
        )
        if adobe_integration:
            other_libraries = adobe_integration.setting(
                cls.OTHER_LIBRARIES_KEY).json_value
        other_libraries = other_libraries or dict()

        if not vendor_id or not library_uri or not library_short_name or not secret:
            raise CannotLoadConfiguration(
                "Short Client Token configuration is incomplete. "
                "vendor_id (%s), username (%s), password (%s) and "
                "Library website_url (%s) must all be defined." %
                (vendor_id, library_uri, library_short_name, secret))
        if "|" in library_short_name:
            raise CannotLoadConfiguration(
                "Library short name cannot contain the pipe character.")
        return cls(vendor_id, library_uri, library_short_name, secret,
                   other_libraries)

    @classmethod
    def adobe_relevant_credentials(self, patron):
        """Find all Adobe-relevant Credential objects for the given
        patron.

        This includes the patron's identifier for Adobe ID purposes,
        and (less likely) any Adobe IDs directly associated with the
        Patron.

        :return: A SQLAlchemy query
        """
        _db = Session.object_session(patron)
        types = (
            AdobeVendorIDModel.VENDOR_ID_UUID_TOKEN_TYPE,
            AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER,
        )
        return (_db.query(Credential).filter(
            Credential.patron == patron).filter(Credential.type.in_(types)))

    def encode(self, patron_identifier):
        """Generate an authdata JWT suitable for putting in an OPDS feed, where
        it can be picked up by a client and sent to the delegation
        authority to look up an Adobe ID.

        :return: A 2-tuple (vendor ID, authdata)
        """
        if not patron_identifier:
            raise ValueError("No patron identifier specified")
        now = utc_now()
        expires = now + datetime.timedelta(minutes=60)
        authdata = self._encode(self.library_uri, patron_identifier, now,
                                expires)
        return self.vendor_id, authdata

    def _encode(self, iss=None, sub=None, iat=None, exp=None):
        """Helper method split out separately for use in tests."""
        payload = dict(iss=iss)  # Issuer
        if sub:
            payload["sub"] = sub  # Subject
        if iat:
            payload["iat"] = self.numericdate(iat)  # Issued At
        if exp:
            payload["exp"] = self.numericdate(exp)  # Expiration Time
        return base64.encodebytes(
            jwt.encode(payload, self.secret, algorithm=self.ALGORITHM))

    @classmethod
    def adobe_base64_encode(cls, str_to_encode):
        """A modified base64 encoding that avoids triggering an Adobe bug.

        The bug seems to happen when the 'password' portion of a
        username/password pair contains a + character. So we replace +
        with :. We also replace / (another "suspicious" character)
        with ;. and strip newlines.
        """
        if isinstance(str_to_encode, str):
            str_to_encode = str_to_encode.encode("utf-8")
        encoded = base64.encodebytes(str_to_encode).decode("utf-8").strip()
        return encoded.replace("+", ":").replace("/", ";").replace("=", "@")

    @classmethod
    def adobe_base64_decode(cls, str):
        """Undoes adobe_base64_encode."""
        encoded = str.replace(":", "+").replace(";", "/").replace("@", "=")
        return base64.decodebytes(encoded.encode("utf-8"))

    def decode(self, authdata):
        """Decode and verify an authdata JWT from one of the libraries managed
        by `secrets_by_library`.

        :return: a 2-tuple (library_uri, patron_identifier)

        :raise jwt.exceptions.DecodeError: When the JWT is not valid
            for any reason.
        """

        self.log.info("Authdata.decode() received authdata %s", authdata)
        # We are going to try to verify the authdata as is (in case
        # Adobe secretly decoded it en route), but we're also going to
        # try to decode it ourselves and verify it that way.
        potential_tokens = [authdata]
        try:
            decoded = base64.decodebytes(authdata)
            potential_tokens.append(decoded)
        except Exception as e:
            # Do nothing -- the authdata was not encoded to begin with.
            pass

        exceptions = []
        library_uri = subject = None
        for authdata in potential_tokens:
            try:
                return self._decode(authdata)
            except Exception as e:
                self.log.error("Error decoding %s", authdata, exc_info=e)
                exceptions.append(e)

        # If we got to this point there is at least one exception
        # in the list.
        raise exceptions[-1]

    def _decode(self, authdata):
        # First, decode the authdata without checking the signature.
        decoded = jwt.decode(authdata,
                             algorithm=self.ALGORITHM,
                             options=dict(verify_signature=False))

        # This lets us get the library URI, which lets us get the secret.
        library_uri = decoded.get("iss")
        if not library_uri in self.secrets_by_library_uri:
            # The request came in without a library specified
            # or with an unknown library specified.
            raise jwt.exceptions.DecodeError("Unknown library: %s" %
                                             library_uri)

        # We know the secret for this library, so we can re-decode the
        # secret and require signature valudation this time.
        secret = self.secrets_by_library_uri[library_uri]
        decoded = jwt.decode(authdata, secret, algorithm=self.ALGORITHM)
        if not "sub" in decoded:
            raise jwt.exceptions.DecodeError("No subject specified.")
        return library_uri, decoded["sub"]

    @classmethod
    def _adobe_patron_identifier(cls, patron):
        """Take patron object and return identifier for Adobe ID purposes"""
        _db = Session.object_session(patron)
        internal = DataSource.lookup(_db, DataSource.INTERNAL_PROCESSING)

        def refresh(credential):
            credential.credential = str(uuid.uuid1())

        patron_identifier = Credential.lookup(
            _db,
            internal,
            AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER,
            patron,
            refresher_method=refresh,
            allow_persistent_token=True,
        )
        return patron_identifier.credential

    def short_client_token_for_patron(self, patron_information):
        """Generate short client token for patron, or for a patron's identifier
        for Adobe ID purposes"""

        if isinstance(patron_information, Patron):
            # Find the patron's identifier for Adobe ID purposes.
            patron_identifier = self._adobe_patron_identifier(
                patron_information)
        else:
            patron_identifier = patron_information

        vendor_id, token = self.encode_short_client_token(patron_identifier)
        return vendor_id, token

    def _now(self):
        """Function to return current time. Used to override in testing."""
        return utc_now()

    def encode_short_client_token(self, patron_identifier, expires=None):
        """Generate a short client token suitable for putting in an OPDS feed,
        where it can be picked up by a client and sent to the
        delegation authority to look up an Adobe ID.

        :return: A 2-tuple (vendor ID, token)
        """
        if expires is None:
            expires = {"minutes": 60}
        if not patron_identifier:
            raise ValueError("No patron identifier specified")
        expires = int(
            self.numericdate(self._now() + datetime.timedelta(**expires)))
        authdata = self._encode_short_client_token(self.short_name,
                                                   patron_identifier, expires)
        return self.vendor_id, authdata

    def _encode_short_client_token(self, library_short_name, patron_identifier,
                                   expires):
        base = library_short_name + "|" + str(
            expires) + "|" + patron_identifier
        signature = self.short_token_signer.sign(base.encode("utf-8"),
                                                 self.short_token_signing_key)
        signature = self.adobe_base64_encode(signature)
        if len(base) > 80:
            self.log.error(
                "Username portion of short client token exceeds 80 characters; Adobe will probably truncate it."
            )
        if len(signature) > 76:
            self.log.error(
                "Password portion of short client token exceeds 76 characters; Adobe will probably truncate it."
            )
        return base + "|" + signature

    def decode_short_client_token(self, token):
        """Attempt to interpret a 'username' and 'password' as a short
        client token identifying a patron of a specific library.

        :return: a 2-tuple (library_uri, patron_identifier)

        :raise ValueError: When the token is not valid for any reason.
        """
        if not "|" in token:
            raise ValueError(
                'Supposed client token "%s" does not contain a pipe.' % token)

        username, password = token.rsplit("|", 1)
        return self.decode_two_part_short_client_token(username, password)

    def decode_two_part_short_client_token(self, username, password):
        """Decode a short client token that has already been split into
        two parts.
        """
        signature = self.adobe_base64_decode(password)
        return self._decode_short_client_token(username, signature)

    def _decode_short_client_token(self, token, supposed_signature):
        """Make sure a client token is properly formatted, correctly signed,
        and not expired.
        """
        if token.count("|") < 2:
            raise ValueError("Invalid client token: %s" % token)
        library_short_name, expiration, patron_identifier = token.split("|", 2)

        library_short_name = library_short_name.upper()
        try:
            expiration = float(expiration)
        except ValueError:
            raise ValueError('Expiration time "%s" is not numeric.' %
                             expiration)

        # We don't police the content of the patron identifier but there
        # has to be _something_ there.
        if not patron_identifier:
            raise ValueError("Token %s has empty patron identifier" % token)

        if not library_short_name in self.library_uris_by_short_name:
            raise ValueError(
                'I don\'t know how to handle tokens from library "%s"' %
                library_short_name)
        library_uri = self.library_uris_by_short_name[library_short_name]
        if not library_uri in self.secrets_by_library_uri:
            raise ValueError("I don't know the secret for library %s" %
                             library_uri)
        secret = self.secrets_by_library_uri[library_uri]

        # Don't bother checking an expired token.
        now = utc_now()
        expiration = self.EPOCH + datetime.timedelta(seconds=expiration)
        if expiration < now:
            raise ValueError("Token %s expired at %s (now is %s)." %
                             (token, expiration, now))

        # Sign the token and check against the provided signature.
        key = self.short_token_signer.prepare_key(secret)
        actual_signature = self.short_token_signer.sign(
            token.encode("utf-8"), key)

        if actual_signature != supposed_signature:
            raise ValueError("Invalid signature for %s." % token)

        return library_uri, patron_identifier

    EPOCH = datetime_utc(1970, 1, 1)

    @classmethod
    def numericdate(cls, d):
        """Turn a datetime object into a NumericDate as per RFC 7519."""
        return (d - cls.EPOCH).total_seconds()

    def migrate_adobe_id(self, patron):
        """If the given patron has an Adobe ID stored as a Credential, also
        store it as a DelegatedPatronIdentifier.

        This method and its test should be removed once all instances have
        run the migration script
        20161102-adobe-id-is-delegated-patron-identifier.py.
        """

        _db = Session.object_session(patron)
        credential = get_one(
            _db,
            Credential,
            patron=patron,
            type=AdobeVendorIDModel.VENDOR_ID_UUID_TOKEN_TYPE,
        )
        if not credential:
            # This patron has no Adobe ID. Do nothing.
            return None, None
        adobe_id = credential.credential

        # Create a new Credential containing an anonymized patron ID.
        patron_identifier_credential = (
            AdobeVendorIDModel.get_or_create_patron_identifier_credential(
                patron))

        # Then create a DelegatedPatronIdentifier mapping that
        # anonymized patron ID to the patron's Adobe ID.
        def create_function():
            """This will be called as the DelegatedPatronIdentifier
            is created. We already know the patron's Adobe ID and just
            want to store it in the DPI.
            """
            return adobe_id

        delegated_identifier, is_new = DelegatedPatronIdentifier.get_one_or_create(
            _db,
            self.library_uri,
            patron_identifier_credential.credential,
            DelegatedPatronIdentifier.ADOBE_ACCOUNT_ID,
            create_function,
        )
        return patron_identifier_credential, delegated_identifier
Ejemplo n.º 16
0
    def test_hmac_should_accept_unicode_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        algo.prepare_key(ensure_unicode('awesome'))
Ejemplo n.º 17
0
Archivo: core.py Proyecto: phizaz/gaw
def _sign(b, secret):
    alg = HMACAlgorithm(HMACAlgorithm.SHA256)
    key = alg.prepare_key(secret)
    signature = alg.sign(b, key)
    return signature
Ejemplo n.º 18
0
    def test_hmac_to_jwk_returns_correct_values(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)
        key = algo.to_jwk("secret")

        assert json.loads(key) == {"kty": "oct", "k": "c2VjcmV0"}
Ejemplo n.º 19
0
    def test_hmac_to_jwk_returns_correct_values(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)
        key = algo.to_jwk('secret')

        assert json.loads(key) == {'kty': 'oct', 'k': 'c2VjcmV0'}
Ejemplo n.º 20
0
    def test_hmac_should_throw_exception(self, key):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with pytest.raises(InvalidKeyError):
            with open(key_path(key)) as keyfile:
                algo.prepare_key(keyfile.read())
Ejemplo n.º 21
0
    def test_hmac_should_accept_unicode_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        algo.prepare_key(ensure_unicode('awesome'))
Ejemplo n.º 22
0
    def test_hmac_should_accept_unicode_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        algo.prepare_key(force_unicode("awesome"))
Ejemplo n.º 23
0
    def test_hmac_should_throw_exception_if_key_is_ssh_public_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with pytest.raises(InvalidKeyError):
            with open(key_path("testkey_rsa.pub"), "r") as keyfile:
                algo.prepare_key(keyfile.read())
Ejemplo n.º 24
0
    def test_hmac_should_throw_exception_if_key_is_x509_certificate(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with pytest.raises(InvalidKeyError):
            with open(key_path("testkey_rsa.cer")) as keyfile:
                algo.prepare_key(keyfile.read())
Ejemplo n.º 25
0
    def test_hmac_should_throw_exception_if_key_is_pkcs1_pem_public(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with pytest.raises(InvalidKeyError):
            with open(key_path("testkey_pkcs1.pub.pem")) as keyfile:
                algo.prepare_key(keyfile.read())
Ejemplo n.º 26
0
class AuthdataUtility(object):
    """Generate authdata JWTs as per the Vendor ID Service spec:
    https://docs.google.com/document/d/1j8nWPVmy95pJ_iU4UTC-QgHK2QhDUSdQ0OQTFR2NE_0    

    Capable of encoding JWTs (for this library), and decoding them
    (from this library and potentially others).

    Also generates and decodes JWT-like strings used to get around
    Adobe's lack of support for authdata in deactivation.
    """

    # The type of the Credential created to identify a patron to the
    # Vendor ID Service. Using this as an alias keeps the Vendor ID
    # Service from knowing anything about the patron's true
    # identity. This Credential is permanent (unlike a patron's
    # username or authorization identifier), but can be revoked (if
    # the patron needs to reset their Adobe account ID) with no
    # consequences other than losing their currently checked-in books.
    ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER = "Identifier for Adobe account ID purposes"

    ALGORITHM = 'HS256'

    def __init__(self,
                 vendor_id,
                 library_uri,
                 library_short_name,
                 secret,
                 other_libraries={}):
        """Basic constructor.

        :param vendor_id: The Adobe Vendor ID that should accompany authdata
        generated by this utility.

        If this library has its own Adobe Vendor ID, it should go
        here. If this library is delegating authdata control to some
        other library, that library's Vendor ID should go here.

        :param library_uri: A URI identifying this library. This is
        used when generating JWTs.

        :param short_name: A short string identifying this
        library. This is used when generating short client tokens,
        which must be as short as possible (thus the name).

        :param secret: A secret used to sign this library's authdata.

        :param other_libraries: A dictionary mapping other libraries'
        canonical URIs to their (short name, secret) 2-tuples. An
        instance of this class will be able to decode an authdata from
        any library in this dictionary (plus the library it was
        initialized for).
        """
        self.vendor_id = vendor_id

        # This is used to _encode_ JWTs and send them to the
        # delegation authority.
        self.library_uri = library_uri

        # This is used to _encode_ short client tokens.
        self.short_name = library_short_name.upper()

        # This is used to encode both JWTs and short client tokens.
        self.secret = secret

        # This is used by the delegation authority to _decode_ JWTs.
        self.secrets_by_library_uri = {}
        self.secrets_by_library_uri[self.library_uri] = secret

        # This is used by the delegation authority to _decode_ short
        # client tokens.
        self.library_uris_by_short_name = {}
        self.library_uris_by_short_name[self.short_name] = self.library_uri

        # Fill in secrets_by_library_uri and library_uris_by_short_name
        # for other libraries.
        for uri, v in other_libraries.items():
            short_name, secret = v
            short_name = short_name.upper()
            if short_name in self.library_uris_by_short_name:
                # This can happen if the same library is in the list
                # twice, capitalized differently.
                raise ValueError("Duplicate short name: %s" % short_name)
            self.library_uris_by_short_name[short_name] = uri
            self.secrets_by_library_uri[uri] = secret

        self.log = logging.getLogger("Adobe authdata utility")

        self.short_token_signer = HMACAlgorithm(HMACAlgorithm.SHA256)
        self.short_token_signing_key = self.short_token_signer.prepare_key(
            self.secret)

    LIBRARY_URI_KEY = 'library_uri'
    LIBRARY_SHORT_NAME_KEY = 'library_short_name'
    AUTHDATA_SECRET_KEY = 'authdata_secret'
    OTHER_LIBRARIES_KEY = 'other_libraries'
    OTHER_LIBRARY_SHORT_NAMES_KEY = 'other_library_short_names'

    @classmethod
    def from_config(cls, _db):
        """Initialize an AuthdataUtility from site configuration.

        :return: An AuthdataUtility if one is configured; otherwise
        None.

        :raise CannotLoadConfiguration: If an AuthdataUtility is
        incompletely configured.
        """
        integration = Configuration.integration(
            Configuration.ADOBE_VENDOR_ID_INTEGRATION)
        if not integration:
            return None
        vendor_id = integration.get(Configuration.ADOBE_VENDOR_ID)
        library_uri = integration.get(cls.LIBRARY_URI_KEY)
        library = Library.instance(_db)
        library_short_name = library.library_registry_short_name
        secret = library.library_registry_shared_secret
        other_libraries = integration.get(cls.OTHER_LIBRARIES_KEY, {})
        if (not vendor_id or not library_uri or not library_short_name
                or not secret):
            raise CannotLoadConfiguration(
                "Adobe Vendor ID configuration is incomplete. %s, %s, library.library_registry_short_name and library.library_registry_shared_secret must all be defined."
                % (cls.LIBRARY_URI_KEY, Configuration.ADOBE_VENDOR_ID))
        if '|' in library_short_name:
            raise CannotLoadConfiguration(
                "Library short name cannot contain the pipe character.")
        return cls(vendor_id, library_uri, library_short_name, secret,
                   other_libraries)

    def encode(self, patron_identifier):
        """Generate an authdata JWT suitable for putting in an OPDS feed, where
        it can be picked up by a client and sent to the delegation
        authority to look up an Adobe ID.

        :return: A 2-tuple (vendor ID, authdata)
        """
        if not patron_identifier:
            raise ValueError("No patron identifier specified")
        now = datetime.datetime.utcnow()
        expires = now + datetime.timedelta(minutes=60)
        authdata = self._encode(self.library_uri, patron_identifier, now,
                                expires)
        return self.vendor_id, authdata

    def _encode(self, iss=None, sub=None, iat=None, exp=None):
        """Helper method split out separately for use in tests."""
        payload = dict(iss=iss)  # Issuer
        if sub:
            payload['sub'] = sub  # Subject
        if iat:
            payload['iat'] = self.numericdate(iat)  # Issued At
        if exp:
            payload['exp'] = self.numericdate(exp)  # Expiration Time
        return base64.encodestring(
            jwt.encode(payload, self.secret, algorithm=self.ALGORITHM))

    @classmethod
    def adobe_base64_encode(cls, str):
        """A modified base64 encoding that avoids triggering an Adobe bug.

        The bug seems to happen when the 'password' portion of a
        username/password pair contains a + character. So we replace +
        with :. We also replace / (another "suspicious" character)
        with ;. and strip newlines.
        """
        encoded = base64.encodestring(str)
        return encoded.replace("+", ":").replace("/",
                                                 ";").replace("=",
                                                              "@").strip()

    @classmethod
    def adobe_base64_decode(cls, str):
        """Undoes adobe_base64_encode."""
        encoded = str.replace(":", "+").replace(";", "/").replace("@", "=")
        return base64.decodestring(encoded)

    def decode(self, authdata):
        """Decode and verify an authdata JWT from one of the libraries managed
        by `secrets_by_library`.

        :return: a 2-tuple (library_uri, patron_identifier)

        :raise jwt.exceptions.DecodeError: When the JWT is not valid
        for any reason.
        """

        self.log.info("Authdata.decode() received authdata %s", authdata)
        # We are going to try to verify the authdata as is (in case
        # Adobe secretly decoded it en route), but we're also going to
        # try to decode it ourselves and verify it that way.
        potential_tokens = [authdata]
        try:
            decoded = base64.decodestring(authdata)
            potential_tokens.append(decoded)
        except Exception, e:
            # Do nothing -- the authdata was not encoded to begin with.
            pass

        exceptions = []
        library_uri = subject = None
        for authdata in potential_tokens:
            try:
                return self._decode(authdata)
            except Exception, e:
                self.log.error("Error decoding %s", authdata, exc_info=e)
                exceptions.append(e)
Ejemplo n.º 27
0
    def test_hmac_from_jwk_should_raise_exception_if_not_hmac_key(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with open(key_path("jwk_rsa_pub.json")) as keyfile:
            with pytest.raises(InvalidKeyError):
                algo.from_jwk(keyfile.read())
Ejemplo n.º 28
0
    def test_hmac_should_throw_exception_if_key_is_x509_cert(self):
        algo = HMACAlgorithm(HMACAlgorithm.SHA256)

        with pytest.raises(InvalidKeyError):
            with open(key_path('testkey2_rsa.pub.pem'), 'r') as keyfile:
                algo.prepare_key(keyfile.read())
Ejemplo n.º 29
0
Archivo: core.py Proyecto: phizaz/gaw
def _sign(b, secret):
    alg = HMACAlgorithm(HMACAlgorithm.SHA256)
    key = alg.prepare_key(secret)
    signature = alg.sign(b, key)
    return signature