Skip to content
Snippets Groups Projects
Commit 067e5634 authored by Vincent Texier's avatar Vincent Texier
Browse files

[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
parent 5c00bd86
No related branches found
No related tags found
No related merge requests found
Pipeline #12523 passed
"""
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]
comment = decrypted_bytes[1 : message_length + 1]
return 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"Comment: {comment_}")
print(f"Encrypted comment: {encrypted_comment_}")
print(f"Decrypted comment: {decrypted_comment_}")
assert comment_ == decrypted_comment_
......@@ -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)
......@@ -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")
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment