diff --git a/python/MANIFEST.in b/python/MANIFEST.in index db6309d..824b377 100644 --- a/python/MANIFEST.in +++ b/python/MANIFEST.in @@ -1,4 +1,5 @@ include include/olm/olm.h include include/olm/pk.h +include include/olm/sas.h include Makefile include olm_build.py diff --git a/python/Makefile b/python/Makefile index 7f0121d..e4d0611 100644 --- a/python/Makefile +++ b/python/Makefile @@ -12,7 +12,10 @@ include/olm/olm.h: $(OLM_HEADERS) include/olm/pk.h: include/olm/olm.h ../include/olm/pk.h $(CPP) -I dummy -I ../include ../include/olm/pk.h -o include/olm/pk.h -headers: include/olm/olm.h include/olm/pk.h +include/olm/sas.h: include/olm/olm.h ../include/olm/sas.h + $(CPP) -I dummy -I ../include ../include/olm/sas.h -o include/olm/sas.h + +headers: include/olm/olm.h include/olm/pk.h include/olm/sas.h olm-python2: headers DEVELOP=$(DEVELOP) python2 setup.py build diff --git a/python/olm/__init__.py b/python/olm/__init__.py index 7b7423b..d92b0ab 100644 --- a/python/olm/__init__.py +++ b/python/olm/__init__.py @@ -21,7 +21,7 @@ Olm Python bindings | © Copyright 2015-2017 by OpenMarket Ltd | © Copyright 2018 by Damir Jelić """ -from .utility import ed25519_verify, OlmVerifyError +from .utility import ed25519_verify, OlmVerifyError, OlmHashError, sha256 from .account import Account, OlmAccountError from .session import ( Session, @@ -43,3 +43,4 @@ from .pk import ( PkEncryptionError, PkDecryptionError ) +from .sas import Sas, OlmSasError diff --git a/python/olm/sas.py b/python/olm/sas.py new file mode 100644 index 0000000..c12b7bc --- /dev/null +++ b/python/olm/sas.py @@ -0,0 +1,257 @@ +# -*- coding: utf-8 -*- +# libolm python bindings +# Copyright © 2019 Damir Jelić +# +# 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. + +"""libolm SAS module. + +This module contains functions to perform key verification using the Short +Authentication String (SAS) method. + +Examples: + >>> sas = Sas() + >>> bob_key = "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08" + >>> message = "Hello world!" + >>> extra_info = "MAC" + >>> sas_alice.set_their_pubkey(bob_key) + >>> sas_alice.calculate_mac(message, extra_info) + >>> sas_alice.generate_bytes(extra_info, 5) + +""" + +from functools import wraps +from builtins import bytes +from typing import Optional + +from future.utils import bytes_to_native_str + +from _libolm import ffi, lib + +from ._compat import URANDOM, to_bytes, to_bytearray +from ._finalize import track_for_finalization + + +def other_pubkey_set(func): + """Ensure that the other pubkey is added to the Sas object.""" + @wraps(func) + def wrapper(self, *args, **kwargs): + if not self.other_key_set: + raise OlmSasError("The other public key isn't set.") + return func(self, *args, **kwargs) + return wrapper + + +def _clear_sas(sas): + # type: (ffi.cdata) -> None + lib.olm_clear_sas(sas) + + +class OlmSasError(Exception): + """libolm Sas error exception.""" + + +class Sas(object): + """libolm Short Authenticaton String (SAS) class.""" + + def __init__(self, other_users_pubkey=None): + # type: (Optional[str]) -> None + """Create a new SAS object. + + Args: + other_users_pubkey(str, optional): The other users public key, this + key is necesary to generate bytes for the authentication string + as well as to calculate the MAC. + + Attributes: + other_key_set (bool): A boolean flag that tracks if we set the + other users public key for this SAS object. + + Raises OlmSasError on failure. + + """ + self._buf = ffi.new("char[]", lib.olm_sas_size()) + self._sas = lib.olm_sas(self._buf) + self.other_key_set = False + track_for_finalization(self, self._sas, _clear_sas) + + random_length = lib.olm_create_sas_random_length(self._sas) + random = URANDOM(random_length) + + self._create_sas(random, random_length) + + if other_users_pubkey: + self.set_their_pubkey(other_users_pubkey) + + def _create_sas(self, buffer, buffer_length): + self._check_error( + lib.olm_create_sas( + self._sas, + ffi.from_buffer(buffer), + buffer_length + ) + ) + + def _check_error(self, ret): + # type: (int) -> None + if ret != lib.olm_error(): + return + + last_error = bytes_to_native_str( + ffi.string((lib.olm_sas_last_error(self._sas)))) + + raise OlmSasError(last_error) + + @property + def pubkey(self): + # type: () -> str + """Get the public key for the SAS object. + + This returns the public key of the SAS object that can then be shared + with another user to perform the authentication process. + + Raises OlmSasError on failure. + + """ + pubkey_length = lib.olm_sas_pubkey_length(self._sas) + pubkey_buffer = ffi.new("char[]", pubkey_length) + + self._check_error( + lib.olm_sas_get_pubkey(self._sas, pubkey_buffer, pubkey_length) + ) + + return bytes_to_native_str(ffi.unpack(pubkey_buffer, pubkey_length)) + + def set_their_pubkey(self, key): + # type: (str) -> None + """Set the public key of the other user. + + This sets the public key of the other user, it needs to be set before + bytes can be generated for the authentication string and a MAC can be + calculated. + + Args: + key (str): The other users public key. + + Raises OlmSasError on failure. + + """ + byte_key = to_bytearray(key) + + self._check_error( + lib.olm_sas_set_their_key( + self._sas, + ffi.from_buffer(byte_key), + len(byte_key) + ) + ) + self.other_key_set = True + + @other_pubkey_set + def generate_bytes(self, extra_info, length): + # type: (str, int) -> bytes + """Generate bytes to use for the short authentication string. + + Args: + extra_info (str): Extra information to mix in when generating the + bytes. + length (int): The number of bytes to generate. + + Raises OlmSasError if the other users persons public key isn't set or + an internal Olm error happens. + + """ + if length < 1: + raise ValueError("The length needs to be a positive integer value") + + byte_info = to_bytearray(extra_info) + out_buffer = ffi.new("char[]", length) + + self._check_error( + lib.olm_sas_generate_bytes( + self._sas, + ffi.from_buffer(byte_info), + len(byte_info), + out_buffer, + length + ) + ) + + return ffi.unpack(out_buffer, length) + + @other_pubkey_set + def calculate_mac(self, message, extra_info): + # type: (str, str) -> str + """Generate a message authentication code based on the shared secret. + + Args: + message (str): The message to produce the authentication code for. + extra_info (str): Extra information to mix in when generating the + MAC + + Raises OlmSasError on failure. + + """ + byte_message = to_bytes(message) + byte_info = to_bytes(extra_info) + + mac_length = lib.olm_sas_mac_length(self._sas) + mac_buffer = ffi.new("char[]", mac_length) + + self._check_error( + lib.olm_sas_calculate_mac( + self._sas, + ffi.from_buffer(byte_message), + len(byte_message), + ffi.from_buffer(byte_info), + len(byte_info), + mac_buffer, + mac_length + ) + ) + return bytes_to_native_str(ffi.unpack(mac_buffer, mac_length)) + + @other_pubkey_set + def calculate_mac_long_kdf(self, message, extra_info): + # type: (str, str) -> str + """Generate a message authentication code based on the shared secret. + + This function should not be used unless compatibility with an older + non-tagged Olm version is required. + + Args: + message (str): The message to produce the authentication code for. + extra_info (str): Extra information to mix in when generating the + MAC + + Raises OlmSasError on failure. + + """ + byte_message = to_bytes(message) + byte_info = to_bytes(extra_info) + + mac_length = lib.olm_sas_mac_length(self._sas) + mac_buffer = ffi.new("char[]", mac_length) + + self._check_error( + lib.olm_sas_calculate_mac_long_kdf( + self._sas, + ffi.from_buffer(byte_message), + len(byte_message), + ffi.from_buffer(byte_info), + len(byte_info), + mac_buffer, + mac_length + ) + ) + return bytes_to_native_str(ffi.unpack(mac_buffer, mac_length)) diff --git a/python/olm/utility.py b/python/olm/utility.py index 121ff63..10d5ab4 100644 --- a/python/olm/utility.py +++ b/python/olm/utility.py @@ -32,6 +32,7 @@ Examples: # pylint: disable=redefined-builtin,unused-import from typing import AnyStr, Type +from future.utils import bytes_to_native_str # pylint: disable=no-name-in-module from _libolm import ffi, lib # type: ignore @@ -49,6 +50,10 @@ class OlmVerifyError(Exception): """libolm signature verification exception.""" +class OlmHashError(Exception): + """libolm hash calculation exception.""" + + class _Utility(object): # pylint: disable=too-few-public-methods """libolm Utility class.""" @@ -64,12 +69,12 @@ class _Utility(object): track_for_finalization(cls, cls._utility, _clear_utility) @classmethod - def _check_error(cls, ret): - # type: (int) -> None + def _check_error(cls, ret, error_class): + # type: (int, Type) -> None if ret != lib.olm_error(): return - raise OlmVerifyError("{}".format( + raise error_class("{}".format( ffi.string(lib.olm_utility_last_error( cls._utility)).decode("utf-8"))) @@ -84,18 +89,41 @@ class _Utility(object): byte_signature = to_bytearray(signature) try: - cls._check_error( - lib.olm_ed25519_verify(cls._utility, byte_key, len(byte_key), - ffi.from_buffer(byte_message), - len(byte_message), - ffi.from_buffer(byte_signature), - len(byte_signature))) + ret = lib.olm_ed25519_verify( + cls._utility, + byte_key, + len(byte_key), + ffi.from_buffer(byte_message), + len(byte_message), + ffi.from_buffer(byte_signature), + len(byte_signature) + ) + + cls._check_error(ret, OlmVerifyError) + finally: # clear out copies of the message, which may be a plaintext if byte_message is not message: for i in range(0, len(byte_message)): byte_message[i] = 0 + @classmethod + def _sha256(cls, input): + # type: (Type[_Utility], AnyStr) -> str + if not cls._utility: + cls._allocate() + + byte_input = to_bytes(input) + hash_length = lib.olm_sha256_length(cls._utility) + hash = ffi.new("char[]", hash_length) + + ret = lib.olm_sha256(cls._utility, byte_input, len(byte_input), + hash, hash_length) + + cls._check_error(ret, OlmHashError) + + return bytes_to_native_str(ffi.unpack(hash, hash_length)) + def ed25519_verify(key, message, signature): # type: (AnyStr, AnyStr, AnyStr) -> None @@ -109,3 +137,14 @@ def ed25519_verify(key, message, signature): signature(bytes): The message signature. """ return _Utility._ed25519_verify(key, message, signature) + + +def sha256(input_string): + # type: (AnyStr) -> str + """Calculate the SHA-256 hash of the input and encodes it as base64. + + Args: + input_string(str): The input for which the hash will be calculated. + + """ + return _Utility._sha256(input_string) diff --git a/python/olm_build.py b/python/olm_build.py index 97ab3b2..0606337 100644 --- a/python/olm_build.py +++ b/python/olm_build.py @@ -43,6 +43,7 @@ ffibuilder.set_source( #include #include #include + #include """, libraries=["olm"], extra_compile_args=compile_args, @@ -54,5 +55,8 @@ with open(os.path.join(PATH, "include/olm/olm.h")) as f: with open(os.path.join(PATH, "include/olm/pk.h")) as f: ffibuilder.cdef(f.read(), override=True) +with open(os.path.join(PATH, "include/olm/sas.h")) as f: + ffibuilder.cdef(f.read(), override=True) + if __name__ == "__main__": ffibuilder.compile(verbose=True) diff --git a/python/tests/sas_test.py b/python/tests/sas_test.py new file mode 100644 index 0000000..9001e67 --- /dev/null +++ b/python/tests/sas_test.py @@ -0,0 +1,99 @@ +from builtins import bytes + +import pytest + +from olm import OlmSasError, Sas + +MESSAGE = "Test message" +EXTRA_INFO = "extra_info" + + +class TestClass(object): + def test_sas_creation(self): + sas = Sas() + assert sas.pubkey + + def test_other_key_setting(self): + sas_alice = Sas() + sas_bob = Sas() + + assert not sas_alice.other_key_set + sas_alice.set_their_pubkey(sas_bob.pubkey) + assert sas_alice.other_key_set + + def test_bytes_generating(self): + sas_alice = Sas() + sas_bob = Sas(sas_alice.pubkey) + + assert sas_bob.other_key_set + + with pytest.raises(OlmSasError): + sas_alice.generate_bytes(EXTRA_INFO, 5) + + sas_alice.set_their_pubkey(sas_bob.pubkey) + + with pytest.raises(ValueError): + sas_alice.generate_bytes(EXTRA_INFO, 0) + + alice_bytes = sas_alice.generate_bytes(EXTRA_INFO, 5) + bob_bytes = sas_bob.generate_bytes(EXTRA_INFO, 5) + + assert alice_bytes == bob_bytes + + def test_mac_generating(self): + sas_alice = Sas() + sas_bob = Sas() + + with pytest.raises(OlmSasError): + sas_alice.calculate_mac(MESSAGE, EXTRA_INFO) + + sas_alice.set_their_pubkey(sas_bob.pubkey) + sas_bob.set_their_pubkey(sas_alice.pubkey) + + alice_mac = sas_alice.calculate_mac(MESSAGE, EXTRA_INFO) + bob_mac = sas_bob.calculate_mac(MESSAGE, EXTRA_INFO) + + assert alice_mac == bob_mac + + def test_cross_language_mac(self): + """Test MAC generating with a predefined key pair. + + This test imports a private and public key from the C test and checks + if we are getting the same MAC that the C code calculated. + """ + alice_private = [ + 0x77, 0x07, 0x6D, 0x0A, 0x73, 0x18, 0xA5, 0x7D, + 0x3C, 0x16, 0xC1, 0x72, 0x51, 0xB2, 0x66, 0x45, + 0xDF, 0x4C, 0x2F, 0x87, 0xEB, 0xC0, 0x99, 0x2A, + 0xB1, 0x77, 0xFB, 0xA5, 0x1D, 0xB9, 0x2C, 0x2A + ] + + bob_key = "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08" + message = "Hello world!" + extra_info = "MAC" + expected_mac = "2nSMTXM+TStTU3RUVTNSVVZUTlNWVlpVVGxOV1ZscFY" + + sas_alice = Sas() + sas_alice._create_sas(bytes(alice_private), 32) + sas_alice.set_their_pubkey(bob_key) + + alice_mac = sas_alice.calculate_mac(message, extra_info) + + assert alice_mac == expected_mac + + def test_long_mac_generating(self): + sas_alice = Sas() + sas_bob = Sas() + + with pytest.raises(OlmSasError): + sas_alice.calculate_mac_long_kdf(MESSAGE, EXTRA_INFO) + + sas_alice.set_their_pubkey(sas_bob.pubkey) + sas_bob.set_their_pubkey(sas_alice.pubkey) + + alice_mac = sas_alice.calculate_mac_long_kdf(MESSAGE, EXTRA_INFO) + bob_mac = sas_bob.calculate_mac_long_kdf(MESSAGE, EXTRA_INFO) + bob_short_mac = sas_bob.calculate_mac(MESSAGE, EXTRA_INFO) + + assert alice_mac == bob_mac + assert alice_mac != bob_short_mac diff --git a/python/tests/utils_test.py b/python/tests/utils_test.py new file mode 100644 index 0000000..a552d12 --- /dev/null +++ b/python/tests/utils_test.py @@ -0,0 +1,29 @@ +import base64 +import hashlib + +from future.utils import bytes_to_native_str +from hypothesis import given +from hypothesis.strategies import text + +from olm import sha256 +from olm._compat import to_bytes + + +class TestClass(object): + @given(text(), text()) + def test_sha256(self, input1, input2): + first_hash = sha256(input1) + second_hash = sha256(input2) + + hashlib_hash = base64.b64encode( + hashlib.sha256(to_bytes(input1)).digest() + ) + + hashlib_hash = bytes_to_native_str(hashlib_hash[:-1]) + + if input1 == input2: + assert first_hash == second_hash + else: + assert first_hash != second_hash + + assert hashlib_hash == first_hash