diff --git a/duniterpy/helpers/transaction.py b/duniterpy/helpers/transaction.py new file mode 100644 index 0000000000000000000000000000000000000000..8a44c68fa9cc1d65e239f7fb84dd2886ee28d0c0 --- /dev/null +++ b/duniterpy/helpers/transaction.py @@ -0,0 +1,191 @@ +""" +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 + + 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) + nonce = random_bytes(16) + padding_length = random.randint(0, 170 - comment_length) + padding = random_bytes(padding_length) + encrypted_comment_length = comment_length + padding_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 + padding) == len( + key + ) + bytes_to_encrypt = comment_length.to_bytes(1, "big") + comment_as_bytes + padding + 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"password: {password.hex()}") + # print(f"key: {key.hex()}") + + return base64.b64encode(data).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: + """ + 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"Receiver: secretID=bob, password=bob {receiver_signing_key.pubkey}") + print(f"Comment: {comment_}") + print(f"Encrypted comment: {encrypted_comment_}") + print(f"Decrypted comment: {decrypted_comment_}") + + assert comment_ == decrypted_comment_ diff --git a/duniterpy/key/signing_key.py b/duniterpy/key/signing_key.py index 45696723d0eb68cc88f65e7f97f737abee8e9b83..547163f9e3e7567028e8fcef3ec9deffe567bac2 100644 --- a/duniterpy/key/signing_key.py +++ b/duniterpy/key/signing_key.py @@ -22,7 +22,7 @@ from typing import Optional, Union, TypeVar, Type import libnacl.sign import pyaes from libnacl.utils import load_key -from hashlib import scrypt +from hashlib import scrypt, sha256 from .scrypt_params import ScryptParams from .base58 import Base58Encoder @@ -509,3 +509,31 @@ Data: {data}""".format( seed = bytes(base64.b64decode(secret)[0:32]) return cls(seed) + + @classmethod + def from_dubp_mnemonic( + cls, mnemonic: str, scrypt_params: ScryptParams = None + ) -> SigningKeyType: + """ + Generate key pair instance from a DUBP mnemonic passphrase + + See https://git.duniter.org/documents/rfcs/blob/dubp-mnemonic/rfc/0014_Dubp_Mnemonic.md + + :param mnemonic: Passphrase generated from a mnemonic algorithm + :param scrypt_params: ScryptParams instance (default=None) + :return: + """ + if scrypt_params is None: + scrypt_params = ScryptParams() + + _password = mnemonic.encode("utf-8") # type: bytes + _salt = sha256(b"dubp" + _password).digest() # type: bytes + _seed = scrypt( + password=_password, + salt=_salt, + n=scrypt_params.N, # 4096 + r=scrypt_params.r, # 16 + p=scrypt_params.p, # 1 + dklen=scrypt_params.seed_length, # 32 + ) # type: bytes + return cls(_seed) diff --git a/tests/key/test_signing_key.py b/tests/key/test_signing_key.py index 1bd9a4ae02935d36c14efd464a512cd49c62cee7..3ddf8b2fcd8304f3f3e9cf073c978a4bf7ed9fe2 100644 --- a/tests/key/test_signing_key.py +++ b/tests/key/test_signing_key.py @@ -14,7 +14,7 @@ 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 os from duniterpy.key import VerifyingKey, SigningKey, PublicKey @@ -144,3 +144,16 @@ class TestSigningKey(unittest.TestCase): sign_key_load.vk.hex(), "d27f4cb2bfadbaf45b61714b896d4639ab90db035aee746611cdd342bdaa8996", ) + + def test_dubp_mnemonic(self): + mnemonic = ( + "tongue cute mail fossil great frozen same social weasel impact brush kind" + ) + + keypair = SigningKey.from_dubp_mnemonic(mnemonic) + + self.assertEqual( + base64.b64encode(keypair.seed).decode("utf-8"), + "qGdvpbP9lJe7ZG4ZUSyu33KFeAEs/KkshAp9gEI4ReY=", + ) + self.assertEqual(keypair.pubkey, "732SSfuwjB7jkt9th1zerGhphs6nknaCBCTozxUcPWPU")