How to encrypt a transaction message that can be read by the Radix Wallet

TODO: Explain process step by step

import os
import bech32
import ecdsa
import hashlib
from ecdsa.curves import SECP256k1
from ecdsa.keys import SigningKey, VerifyingKey
from Crypto.Protocol.KDF import scrypt
from Crypto.Cipher import AES

# Encrypted Message Parameters
plaintext_message=b"Hey Bob, this is Alice, you and I can read this message, but no one else."
alice_private_key_hex = "0000000000000000000000000000000000000000000000000000000000000001"
bob_wallet_address = "rdx1qspvvprlj3q76ltdxpz5qm54cp7dshrh3e9cemeu5746czdet3cfaegp8alwf"

# Convert receiver's wallet address into a public key
_hrp, bob_readdr_5bit = bech32.bech32_decode(bob_wallet_address)
bob_readdr_bytes = bech32.convertbits(bob_readdr_5bit, 5, 8, pad=False)
bob_public_key_bytes = bytes(bob_readdr_bytes)[1:34]
bob_public_key = VerifyingKey.from_string(bob_public_key_bytes, curve=SECP256k1, hashfunc=hashlib.sha256)

print("Bob Public Key:", bob_public_key.to_string("compressed").hex())


alice_private_key = SigningKey.from_string(bytearray.fromhex(alice_private_key_hex), curve=SECP256k1, hashfunc=hashlib.sha256)

# Diffie-Hellman - we need to use lower level functions here so that we get back dh as a curve point
dh = bob_public_key.pubkey.point * alice_private_key.privkey.secret_multiplier

# Create Ephemeral Key
ephemeral_private_key = ecdsa.SigningKey.generate(curve=ecdsa.SECP256k1, hashfunc=hashlib.sha256)
ephemeral_public_key = ephemeral_private_key.get_verifying_key()

print("Ephemeral Public Key:", ephemeral_public_key.to_string("compressed").hex())

ephemeral_point = ephemeral_public_key.pubkey.point

# Elliptic Curve Point Addition
shared_secret_point = dh + ephemeral_point
shared_secret_integer = shared_secret_point.x()
shared_secret = int.to_bytes(shared_secret_integer, 32, 'big')

print("Shared Secret: ", shared_secret.hex())

# Create nonce
nonce_bytes = os.urandom(12)
print("Nonce: ", nonce_bytes.hex())

# Generate Scrypt salt from nonce
salt = hashlib.sha256(nonce_bytes).digest()
print("Scrypt Salt: ", salt.hex())

# Create the Encryption Key
key = scrypt(shared_secret, salt, 32, 8192, 8 ,1)

print("Encryption Key:", key.hex())

encrypt = AES.new(key, AES.MODE_GCM, nonce=nonce_bytes)

# Add the Ephemeral Public Key as Associated Data
encrypt.update(ephemeral_public_key.to_string("compressed"))

# Perform the Encryption and generate Ciphertext with related Authentication Tag
ciphertext, auth_tag = encrypt.encrypt_and_digest(plaintext_message)

print("Ciphertext:", ciphertext.hex())
print("Auth Tag:", auth_tag.hex())

# Encode the message to submit
msg = bytearray(b'\x01')
msg += bytearray(b'\xff')
msg += bytearray(ephemeral_public_key.to_string("compressed"))
msg += bytearray(nonce_bytes)
msg += bytearray(auth_tag)
msg += bytearray(ciphertext)

print("Encoded Encrypted Message:", msg.hex())

Run this on Replit:

Also see: How to decrypt an encrypted transaction message created by the Radix Wallet

This work by RadixPool.com is licensed under a Creative Commons Attribution 4.0 International License.

Bob Public Key: 02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5
Ephemeral Public Key: 03097444dbe5faf31ebd25d1b78b6ef35641e3f8f6b66dea9c0eb727170ef15cf6
Shared Secret: 2902821ac3b6bfb391d54b4677b31603cd067593fd4b8a88297870e2a9de396
Nonce:  5b46d8fac9e9769376de3678
Scrypt Salt:  f0f90635843a62adfdeff3ea8be8280bd5d3b86e6913783a029fd2857f36e288
Traceback (most recent call last):
  File "main.py", line 52, in <module>
    key = scrypt(bytearray.fromhex(shared_secret_hex), salt, 32, 8192, 8 ,1)
ValueError: non-hexadecimal number found in fromhex() arg at position 63

The encrypt message currently have issue that the code shared_secret_hex = hex(shared_secret_integer)[2:] could return the odd number of characters, and later call bytearray.fromhex(shared_secret_hex) will fail because method bytearray.fromhex() expect string with even number of characters.

I am not sure how Radix wallet handle this case, I will try to take a look at Radix typescript source.

Thanks for reporting that. I’m not really a Python user so it’s not the best code… I’ll take a look and see if I can figure it out. Might be a padding issue.

From the typescript at this url https://github.com/radixdlt/radixdlt-javascript/blob/16b200131729ffb1d2a99f5f4d281a3e3fead467/packages/crypto/src/encryption/messageEncryption.ts#L44

The code is
return Buffer.from(sharedSecretPoint.x.toString(16), ‘hex’)

It is quite difficult to check the detailed implementation how to handle the padding or not handling at all (then it means it also possible bug here)

From Nodejs document https://nodejs.org/api/buffer.html, the convert will truncate the odd character, so Buffer.from(‘30315’, ‘hex’) will be the same as Buffer.from(‘3031’, ‘hex’)

1 Like

I replaced the silly integerhex stringbytes conversion:

shared_secret_hex = hex(shared_secret_integer)[2:]
...
key = scrypt(bytearray.fromhex(shared_secret_hex), salt, 32, 8192, 8 ,1)

with a straight integer into bytes conversion:

shared_secret = int.to_bytes(shared_secret_integer, 32, 'big')
...
key = scrypt(shared_secret, salt, 32, 8192, 8 ,1)

Need to do a bit more testing to ensure it is compatible with the javascript version…