kevinheavey / solders

A high-performance Python toolkit for Solana, written in Rust
https://kevinheavey.github.io/solders/
Apache License 2.0
232 stars 26 forks source link

Signature mismatch while signing JSON message #104

Closed monkeyden closed 3 months ago

monkeyden commented 3 months ago

I'm making a call to the RugCheck API and need to authenticate in order to get higher rate limits. I have what I think is a fairly simple example.

Overall synopsis: String secret key is converted to an int array Keypair constructed via Keypair.from_bytes() Message signed and verified Signature converted to byte array Body constructed with message and signature Request sent

import json
import os
import time
import aiohttp
import asyncio
from solders.keypair import Keypair

# Load the configuration file
def load_config():
    return 'your secret key here'

# Generate the keypair from the stored secret key
def get_keypair():
    secret_key = load_config()
    secret_key_list = [int(x) for x in secret_key.split(',')]
    keypair = Keypair.from_bytes(secret_key_list)
    return keypair

# Sign the message using the keypair
def sign_message(keypair, message_obj):
    # Convert the message object to JSON string and then to bytes
    message_json = json.dumps(message_obj).encode('utf-8')
    return keypair.sign_message(message_json)

# Authenticate with the RugCheck API
async def authenticate_with_rugcheck():
    keypair = get_keypair()
    public_key = keypair.pubkey()

    # Construct the message object to sign
    message_to_sign = {
        "message": "Sign-in to Rugcheck.xyz",
        "timestamp": int(time.time() * 1000),  # Use milliseconds like in JavaScript
        "publicKey": str(public_key)
    }

    # Sign the message
    signature = sign_message(keypair, message_to_sign)

    # Verify with Solders
    message_bytes = json.dumps(message_to_sign).encode('utf-8')    
    is_valid = signature.verify(public_key, message_bytes)
    assert is_valid, "Signature verification failed!"

    # Convert the signature to a list of integers
    signature_ints = signature.to_bytes_array()

    # Prepare the authentication request payload
    request_body = {
        "signature": {
            "data": signature_ints,
            "type": "ed25519"
        },
        "wallet": str(public_key),
        "message": message_to_sign
    }

    # Send the request to the RugCheck API
    async with aiohttp.ClientSession() as session:
        async with session.post('https://api.rugcheck.xyz/auth/login/solana', json=request_body, headers={"Content-Type": "application/json"}) as response:
            response_text = await response.text()
            if response.status == 200:
                data = await response.json()
                return data['token']
            else:
                print(f"Authentication failed. Status: {response.status}, Response: {response_text}")
                return None

# Main function to run the authentication
async def main():
    token = await authenticate_with_rugcheck()
    print(f"Token: {token}")

# Run the authentication process
asyncio.run(main())

Seems very simple but I get this back from RugCheck: Authentication failed. Status: 400, Response: {"error":"signature mismatch"}

Here is the payload sent:

{
    "signature": {
        "data": [
            251,
            69,
            255,
            110,
            133,
            177,
            178,
            242,
            125,
            28,
            167,
            93,
            168,
            204,
            130,
            32,
            179,
            66,
            129,
            7,
            242,
            96,
            250,
            159,
            162,
            1,
            89,
            17,
            209,
            0,
            7,
            179,
            93,
            86,
            84,
            65,
            48,
            179,
            143,
            201,
            252,
            46,
            143,
            35,
            95,
            239,
            24,
            90,
            225,
            95,
            40,
            55,
            164,
            99,
            63,
            208,
            2,
            55,
            12,
            215,
            193,
            222,
            203,
            12
        ],
        "type": "ed25519"
    },
    "wallet": "4bKmYhhabZmUDWV6x9z4L79kaBMUBufE7NcL21nKHgN3",
    "message": {
        "message": "Sign-in to Rugcheck.xyz",
        "timestamp": 1723126687249,
        "publicKey": "4bKmYhhabZmUDWV6x9z4L79kaBMUBufE7NcL21nKHgN3"
    }
}

I have a near carbon copy in JavaScript that works, so I know the endpoint works. Clearly I am doing something wrong.

kevinheavey commented 3 months ago

Can you check if the signature_ints and message_bytes look the same in your JS code?

monkeyden commented 3 months ago

The message is the same but the signature isn't.

message_ints Python: [123, 34, 109, 101, 115, 115, 97, 103, 101, 34, 58, 32, 34, 83, 105, 103, 110, 45, 105, 110, 32, 116, 111, 32, 82, 117, 103, 99, 104, 101, 99, 107, 46, 120, 121, 122, 34, 44, 32, 34, 116, 105, 109, 101, 115, 116, 97, 109, 112, 34, 58, 32, 49, 55, 50, 51, 48, 55, 55, 50, 48, 53, 57, 51, 57, 44, 32, 34, 112, 117, 98, 108, 105, 99, 75, 101, 121, 34, 58, 32, 34, 52, 98, 75, 109, 89, 104, 104, 97, 98, 90, 109, 85, 68, 87, 86, 54, 120, 57, 122, 52, 76, 55, 57, 107, 97, 66, 77, 85, 66, 117, 102, 69, 55, 78, 99, 76, 50, 49, 110, 75, 72, 103, 78, 51, 34, 125]

JavaScript: [123, 34, 109, 101, 115, 115, 97, 103, 101, 34, 58, 34, 83, 105, 103, 110, 45, 105, 110, 32, 116, 111, 32, 82, 117, 103, 99, 104, 101, 99, 107, 46, 120, 121, 122, 34, 44, 34, 116, 105, 109, 101, 115, 116, 97, 109, 112, 34, 58, 49, 55, 50, 51, 48, 55, 55, 50, 48, 53, 57, 51, 57, 44, 34, 112, 117, 98, 108, 105, 99, 75, 101, 121, 34, 58, 34, 52, 98, 75, 109, 89, 104, 104, 97, 98, 90, 109, 85, 68, 87, 86, 54, 120, 57, 122, 52, 76, 55, 57, 107, 97, 66, 77, 85, 66, 117, 102, 69, 55, 78, 99, 76, 50, 49, 110, 75, 72, 103, 78, 51, 34, 125]

signature_ints Python: [95, 228, 167, 34, 209, 99, 172, 247, 172, 117, 3, 116, 31, 1, 148, 248, 26, 140, 137, 132, 123, 77, 175, 37, 122, 55, 254, 39, 113, 187, 42, 167, 48, 233, 81, 111, 30, 72, 188, 127, 78, 81, 185, 241, 128, 238, 96, 57, 211, 90, 146, 65, 203, 78, 223, 31, 144, 253, 127, 181, 108, 25, 133, 15]

JavaScript: [176, 49, 77, 147, 97, 239, 203, 182, 66, 157, 32, 225, 226, 111, 146, 191, 135, 69, 12, 92, 246, 181, 58, 188, 56, 195, 244, 141, 227, 215, 43, 180, 22, 39, 60, 64, 2, 17, 119, 10, 159, 109, 156, 214, 88, 50, 164, 195, 114, 187, 143, 63, 96, 238, 253, 242, 83, 144, 61, 201, 195, 47, 166, 12]

Here is the JS if you're curious:

<!DOCTYPE html>
<html>
<head>
    <title>Solana Sign Message</title>
    <script src="https://unpkg.com/@solana/web3.js@latest/lib/index.iife.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/tweetnacl/nacl.min.js"></script>
</head>
<body>

<script>
    (async () => {
        // Generate a new Keypair or use your existing secret key
        const secretKey = Uint8Array.from([your secret key array here]);
        const keypair = solanaWeb3.Keypair.fromSecretKey(secretKey);

        // Create the message object to sign
        const messageObj = {
            message: "Sign-in to Rugcheck.xyz",
            timestamp: 1723077205939,  // Replace this with your specific timestamp
            publicKey: keypair.publicKey.toString()
        };

        // Convert message object to bytes (UTF-8 encoding)
        const encodedMessage = new TextEncoder().encode(JSON.stringify(messageObj));
        console.log("Message bytes in JavaScript:", Array.from(encodedMessage));

        // Sign the message using TweetNacl
        const signedMessage = nacl.sign.detached(encodedMessage, keypair.secretKey);

        console.log("Signed message:", Array.from(signedMessage));
        console.log("Public Key:", keypair.publicKey.toString());
        console.log("Message to sign:", JSON.stringify(messageObj));
    })();
</script>

</body>
</html>
kevinheavey commented 3 months ago

Oh the message ints are different:

In Python we have 58, 32, 34

Whereas in JS we have 58, 34, 83

monkeyden commented 3 months ago

Python:

This could simply be the way JSON is serialized between the two. Python seems to use spaces more liberally

python_bytes = [123, 34, 109, 101, 115, 115, 97, 103, 101, 34, 58, 32, 34, 83, 105, 103, 110, 45, 105, 110, 32, 116, 111, 32, 82, 117, 103, 99, 104, 101, 99, 107, 46, 120, 121, 122, 34, 44, 32, 34, 116, 105, 109, 101, 115, 116, 97, 109, 112, 34, 58, 32, 49, 55, 50, 51, 48, 55, 55, 50, 48, 53, 57, 51, 57, 44, 32, 34, 112, 117, 98, 108, 105, 99, 75, 101, 121, 34, 58, 32, 34, 52, 98, 75, 109, 89, 104, 104, 97, 98, 90, 109, 85, 68, 87, 86, 54, 120, 57, 122, 52, 76, 55, 57, 107, 97, 66, 77, 85, 66, 117, 102, 69, 55, 78, 99, 76, 50, 49, 110, 75, 72, 103, 78, 51, 34, 125]

javascript_bytes = [123, 34, 109, 101, 115, 115, 97, 103, 101, 34, 58, 34, 83, 105, 103, 110, 45, 105, 110, 32, 116, 111, 32, 82, 117, 103, 99, 104, 101, 99, 107, 46, 120, 121, 122, 34, 44, 34, 116, 105, 109, 101, 115, 116, 97, 109, 112, 34, 58, 49, 55, 50, 51, 48, 55, 55, 50, 48, 53, 57, 51, 57, 44, 34, 112, 117, 98, 108, 105, 99, 75, 101, 121, 34, 58, 34, 52, 98, 75, 109, 89, 104, 104, 97, 98, 90, 109, 85, 68, 87, 86, 54, 120, 57, 122, 52, 76, 55, 57, 107, 97, 66, 77, 85, 66, 117, 102, 69, 55, 78, 99, 76, 50, 49, 110, 75, 72, 103, 78, 51, 34, 125]

differences = [(i, pb, jb, chr(pb), chr(jb)) for i, (pb, jb) in enumerate(zip(python_bytes, javascript_bytes)) if pb != jb]

for index, p_byte, j_byte, p_char, j_char in differences:
    print(f"Difference at index {index}: Python byte {p_byte} ('{p_char}') vs JavaScript byte {j_byte} ('{j_char}')")
kevinheavey commented 3 months ago

yeah this is just something to do with JSON and spaces - before Solders has any involvement

monkeyden commented 3 months ago

For posterity:

I was indeed doing something wrong. So I'll take full credit for being right there. :sob:

The problem was that I wasn't specifying separators: json.dumps(request_body) when it should have been: json.dumps(request_body, separators=(',', ':'))

Turns out that, when you don't specify the correct separators, dumps() serializes them as "," or ":" followed by a space, which explains the byte array deltas you found.

Thanks for the quick response, and for making solders available to us of course.