From 446628753b6283f6dcbf27589b2c18f93c71768b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Tue, 2 Apr 2019 12:54:00 +0200 Subject: [PATCH 1/5] python: Add Short Authentication String bindings. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch adds bindings to the SAS part of the Olm library contained in the sas.h header file. Signed-off-by: Damir Jelić --- python/Makefile | 7 +- python/olm/__init__.py | 1 + python/olm/sas.py | 223 +++++++++++++++++++++++++++++++++++++++ python/olm_build.py | 4 + python/tests/sas_test.py | 82 ++++++++++++++ 5 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 python/olm/sas.py create mode 100644 python/tests/sas_test.py diff --git a/python/Makefile b/python/Makefile index 5da703a..6283fb5 100644 --- a/python/Makefile +++ b/python/Makefile @@ -6,10 +6,13 @@ include/olm/olm.h: ../include/olm/olm.h ../include/olm/inbound_group_session.h . # add memset to the header so that we can use it to clear buffers echo 'void *memset(void *s, int c, size_t n);' >> include/olm/olm.h -olm-python2: include/olm/olm.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 + +olm-python2: include/olm/olm.h include/olm/sas.h DEVELOP=$(DEVELOP) python2 setup.py build -olm-python3: include/olm/olm.h +olm-python3: include/olm/olm.h include/olm/sas.h DEVELOP=$(DEVELOP) python3 setup.py build install: install-python2 install-python3 diff --git a/python/olm/__init__.py b/python/olm/__init__.py index 015c4f8..fe3e199 100644 --- a/python/olm/__init__.py +++ b/python/olm/__init__.py @@ -36,3 +36,4 @@ from .group_session import ( OutboundGroupSession, OlmGroupSessionError ) +from .sas import Sas, OlmSasError diff --git a/python/olm/sas.py b/python/olm/sas.py new file mode 100644 index 0000000..c7cccb2 --- /dev/null +++ b/python/olm/sas.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# libolm python bindings +# Copyright © 2015-2017 OpenMarket Ltd +# 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)) diff --git a/python/olm_build.py b/python/olm_build.py index ee836d8..1c610a1 100644 --- a/python/olm_build.py +++ b/python/olm_build.py @@ -39,6 +39,7 @@ ffibuilder.set_source( #include #include #include + #include """, libraries=["olm"], extra_compile_args=compile_args, @@ -47,5 +48,8 @@ ffibuilder.set_source( with open(os.path.join(PATH, "include/olm/olm.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..05013b7 --- /dev/null +++ b/python/tests/sas_test.py @@ -0,0 +1,82 @@ +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 From fcfa5f12a4cd482973fdf03000af4a26a360dc2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Tue, 2 Apr 2019 12:56:06 +0200 Subject: [PATCH 2/5] python: Expose the sha256() function in the utilities. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Damir Jelić --- python/olm/__init__.py | 2 +- python/olm/utility.py | 57 ++++++++++++++++++++++++++++++++------ python/tests/utils_test.py | 29 +++++++++++++++++++ 3 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 python/tests/utils_test.py diff --git a/python/olm/__init__.py b/python/olm/__init__.py index fe3e199..9ac45b0 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, 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/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 From 659eb34fa4a28a94dd18d7dd743f6a6c032f2089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Tue, 9 Apr 2019 10:47:26 +0200 Subject: [PATCH 3/5] python: Remove an unneeded and old copyright header. --- python/olm/sas.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/olm/sas.py b/python/olm/sas.py index c7cccb2..8574cfd 100644 --- a/python/olm/sas.py +++ b/python/olm/sas.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # libolm python bindings -# Copyright © 2015-2017 OpenMarket Ltd # Copyright © 2019 Damir Jelić # # Licensed under the Apache License, Version 2.0 (the "License"); From 32b99b793572c109fe0f3507705a10aefe2d8cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Tue, 9 Apr 2019 10:57:36 +0200 Subject: [PATCH 4/5] python: Add support for the long KDF MAC calculation. --- python/olm/sas.py | 35 +++++++++++++++++++++++++++++++++++ python/tests/sas_test.py | 17 +++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/python/olm/sas.py b/python/olm/sas.py index 8574cfd..c12b7bc 100644 --- a/python/olm/sas.py +++ b/python/olm/sas.py @@ -220,3 +220,38 @@ class Sas(object): ) ) 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/tests/sas_test.py b/python/tests/sas_test.py index 05013b7..9001e67 100644 --- a/python/tests/sas_test.py +++ b/python/tests/sas_test.py @@ -80,3 +80,20 @@ class TestClass(object): 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 From 54cb52e05e0d715c9e3c71ffeff05050732dea41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Tue, 9 Apr 2019 11:41:44 +0200 Subject: [PATCH 5/5] python: Add the SAS header to the manifest. The SAS header is required to build the package therefore it needs to be shipped with the source distribution of the package. Adding it to the manifest achieves this. --- python/MANIFEST.in | 1 + 1 file changed, 1 insertion(+) 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