From 807a54d5a7c7a307c3649512fc877754485573d3 Mon Sep 17 00:00:00 2001
From: Vincent Texier <vit@free.fr>
Date: Fri, 19 Mar 2021 19:48:12 +0100
Subject: [PATCH] [feat] #164 Add encryption/decryption for transaction comment
 in helpers

See RFC 0017 https://git.duniter.org/documents/rfcs/blob/tx_comment_encrypt/rfc/0017_transaction_comment_encryption.md
---
 duniterpy/helpers/transaction.py | 205 +++++++++++++++++++++++++++++++
 tests/helpers/transaction.py     |  62 ++++++++++
 2 files changed, 267 insertions(+)
 create mode 100644 duniterpy/helpers/transaction.py
 create mode 100644 tests/helpers/transaction.py

diff --git a/duniterpy/helpers/transaction.py b/duniterpy/helpers/transaction.py
new file mode 100644
index 00000000..e44b0f5f
--- /dev/null
+++ b/duniterpy/helpers/transaction.py
@@ -0,0 +1,205 @@
+"""
+Copyright  2014-2021 Vincent Texier <vit@free.fr>
+
+DuniterPy is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+DuniterPy is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+import base64
+import random
+from hashlib import scrypt
+
+import base58
+from libnacl import (
+    crypto_box_beforenm,
+    crypto_sign_ed25519_pk_to_curve25519,
+    crypto_box_seed_keypair,
+)
+
+from duniterpy.key import SigningKey
+
+TRANSACTION_ENCRYPTION_MAGIC_VALUE = 0x27B6
+TRANSACTION_ENCRYPTION_MESSAGE_TYPE_FOR_HUMAN = 0x00
+TRANSACTION_ENCRYPTION_MESSAGE_TYPE_FOR_MACHINE = 0x01
+
+
+def random_bytes(size: int) -> bytes:
+    """
+    Return random bytes of given length
+
+    :param size: Size of nonce in bytes
+    :return:
+    """
+    return bytearray(random.getrandbits(8) for _ in range(size))
+
+
+def byte_xor(bytes1, bytes2):
+    """
+    XOR two byte strings
+
+    :param bytes1: First string
+    :param bytes2: Second string
+    :return:
+    """
+    return bytes([_a ^ _b for _a, _b in zip(bytes1, bytes2)])
+
+
+def encrypt_comment(
+    comment: str,
+    signing_key: SigningKey,
+    pubkey: str,
+    message_type: int = TRANSACTION_ENCRYPTION_MESSAGE_TYPE_FOR_HUMAN,
+) -> str:
+    """
+    Official Duniter Transaction Comment Encryption
+
+    Comment to encrypt maximum length is 170 characters.
+
+    see RFC 0017 https://git.duniter.org/documents/rfcs/blob/tx_comment_encrypt/rfc/0017_transaction_comment_encryption.md
+
+    :param comment: Message to encrypt
+    :param signing_key: SigningKey instance of the sender
+    :param pubkey: Base58 receiver public key
+    :param message_type: Code defining the type of message (default=TRANSACTION_ENCRYPTION_MESSAGE_TYPE_HUMAN_HUMAN)
+    :return:
+    """
+    version = 0x01
+    comment_as_bytes = comment.encode("utf-8")
+    comment_length = len(comment_as_bytes)
+    assert comment_length <= 170
+
+    nonce = random_bytes(16)
+    extra_bytes_length = random.randint(0, 170 - comment_length)
+    extra_bytes = random_bytes(extra_bytes_length)
+    encrypted_comment_length = comment_length + extra_bytes_length + 1
+
+    # convert keys to curve25519
+    x25519_pubkey = crypto_sign_ed25519_pk_to_curve25519(base58.b58decode(pubkey))
+    # _, x25519_seckey = crypto_sign_seed_keypair(signing_key.seed)
+    _, x25519_seckey = crypto_box_seed_keypair(signing_key.seed)
+
+    # compute password
+    password = crypto_box_beforenm(x25519_pubkey, x25519_seckey[:32])
+
+    # compute key
+    key = scrypt(
+        password=password, salt=nonce, n=1024, r=8, p=1, dklen=encrypted_comment_length
+    )
+
+    # encrypt with XOR
+    assert len(
+        comment_length.to_bytes(1, "big") + comment_as_bytes + extra_bytes
+    ) == len(key)
+    bytes_to_encrypt = (
+        comment_length.to_bytes(1, "big") + comment_as_bytes + extra_bytes
+    )
+    encrypted_comment = byte_xor(bytes_to_encrypt, key)
+
+    data = (
+        TRANSACTION_ENCRYPTION_MAGIC_VALUE.to_bytes(2, "big")
+        + version.to_bytes(1, "big")
+        + message_type.to_bytes(1, "big")
+        + nonce
+        + encrypted_comment
+    )
+
+    # debug
+    # print(f"nonce: {nonce.hex()}")
+    # print(f"extra_bytes: {extra_bytes.hex()}")
+    # print(f"password: {password.hex()}")
+    # print(f"key: {key.hex()}")
+
+    # encode as b64 string with no padding
+    # remove padding manually (padding is always added by Python b64encode implementation)
+    # see https://bugs.python.org/issue29427
+    return base64.b64encode(data).rstrip(b"=").decode("utf-8")
+
+
+def decrypt_comment(comment: str, signing_key: SigningKey, pubkey: str) -> str:
+    """
+    Official Duniter Transaction Comment Decryption
+
+    see RFC 0017 https://git.duniter.org/documents/rfcs/blob/tx_comment_encrypt/rfc/0017_transaction_comment_encryption.md
+
+    :param comment: Encrypted message
+    :param signing_key: SigningKey instance of the receiver
+    :param pubkey: Public key of the sender
+    :return:
+    """
+    # add padding (required by Python b64decode implementation)
+    # see https://bugs.python.org/issue29427
+    data = base64.b64decode(comment + "==")
+    data_length = len(data)
+    assert data_length >= 3
+
+    # extract data format and version
+    magic_value = data[:2]
+    version = data[2]
+
+    if (
+        magic_value == TRANSACTION_ENCRYPTION_MAGIC_VALUE.to_bytes(2, "big")
+        and version == 1
+    ):
+        assert data_length >= 21
+        # message_type = data[3]
+        nonce = data[4:20]
+        encrypted_comment = data[20:]
+    else:
+        raise NotImplementedError("encrypted comment data format not supported")
+
+    # convert keys to curve25519
+    x25519_pubkey = crypto_sign_ed25519_pk_to_curve25519(base58.b58decode(pubkey))
+    # _, x25519_seckey = crypto_sign_seed_keypair(signing_key.seed)
+    _, x25519_seckey = crypto_box_seed_keypair(signing_key.seed)
+    # compute password
+    password = crypto_box_beforenm(x25519_pubkey, x25519_seckey[:32])
+    # compute key
+    key = scrypt(
+        password=password, salt=nonce, n=1024, r=8, p=1, dklen=data_length - 20
+    )
+
+    # decrypt with XOR
+    assert len(encrypted_comment) == len(key)
+    decrypted_bytes = byte_xor(encrypted_comment, key)
+
+    # extract data
+    message_length = decrypted_bytes[0]
+    decrypted_comment = decrypted_bytes[1 : message_length + 1]  # type: bytes
+
+    return decrypted_comment.decode("utf-8")
+
+
+if __name__ == "__main__":
+
+    sender_signing_key = SigningKey.from_credentials("alice", "alice")
+    receiver_signing_key = SigningKey.from_credentials("bob", "bob")
+
+    comment_ = "My taylor is rich ? Isn't it ? Un été 42..."
+    encrypted_comment_ = encrypt_comment(
+        comment_,
+        sender_signing_key,
+        receiver_signing_key.pubkey,
+        TRANSACTION_ENCRYPTION_MESSAGE_TYPE_FOR_HUMAN,
+    )
+    decrypted_comment_ = decrypt_comment(
+        encrypted_comment_, receiver_signing_key, sender_signing_key.pubkey
+    )
+
+    print(f"Sender: secretID=alice, password=alice -> {sender_signing_key.pubkey}")
+    print(f"Seed: {sender_signing_key.seed.hex()}")
+    print(f"Receiver: secretID=bob, password=bob {receiver_signing_key.pubkey}")
+    print(f"Seed: {receiver_signing_key.seed.hex()}")
+    print(f"Comment: {comment_}")
+    print(f"Encrypted comment: {encrypted_comment_}")
+    print(f"Decrypted comment: {decrypted_comment_}")
+
+    assert comment_ == decrypted_comment_
diff --git a/tests/helpers/transaction.py b/tests/helpers/transaction.py
new file mode 100644
index 00000000..fc601222
--- /dev/null
+++ b/tests/helpers/transaction.py
@@ -0,0 +1,62 @@
+"""
+Copyright  2014-2021 Vincent Texier <vit@free.fr>
+
+DuniterPy is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+DuniterPy is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+
+import unittest
+
+from duniterpy.helpers.transaction import (
+    encrypt_comment,
+    TRANSACTION_ENCRYPTION_MESSAGE_TYPE_FOR_HUMAN,
+    decrypt_comment,
+)
+from duniterpy.key import SigningKey
+
+
+class TestHelpersTransaction(unittest.TestCase):
+    def test_encrypt_decrypt(self):
+        """
+        Test comment encryption/decryption
+
+        See RFC 0017 https://git.duniter.org/documents/rfcs/blob/tx_comment_encrypt/rfc/0017_transaction_comment_encryption.md
+        """
+        sender_signing_key = SigningKey.from_credentials("alice", "alice")
+        receiver_signing_key = SigningKey.from_credentials("bob", "bob")
+
+        self.assertTrue(
+            sender_signing_key.seed
+            == 0x0C6A15D0004D24A40F6503300D4971032FB057FF7DC229D651683B413C96A216 .to_bytes(
+                32, "big"
+            )
+        )
+        self.assertTrue(
+            receiver_signing_key.seed
+            == 0x214955B558C7793268589E557EDA2AB69B5293B73D3907F82C82E12D69C47221 .to_bytes(
+                32, "big"
+            )
+        )
+
+        comment_ = "My taylor is rich ? Isn't it ? Un été 42..."
+        encrypted_comment_ = encrypt_comment(
+            comment_,
+            sender_signing_key,
+            receiver_signing_key.pubkey,
+            TRANSACTION_ENCRYPTION_MESSAGE_TYPE_FOR_HUMAN,
+        )
+        decrypted_comment_ = decrypt_comment(
+            encrypted_comment_, receiver_signing_key, sender_signing_key.pubkey
+        )
+
+        self.assertTrue(comment_ == decrypted_comment_)
-- 
GitLab