Unable to verify message signed by sol-wallet-adapter

Viewed 5622

Having created a signed message I'm unsure how to use the resulting signature to verify the message using the publicKey.

My use case is, I'm wanting to use a Solana Wallet to login to an API server with a pattern like:

  1. GET message: String (from API server)
  2. sign message with privateKey
  3. POST signature (to API server)
  4. verify signature with stored publicKey

I've attempted to use nodeJS crypto.verify to decode the signed message on the API side but am a bit out of my depth digging into Buffers and elliptic curves:

// Front-end code
const toHexString = (buffer: Buffer) =>
  buffer.reduce((str, byte) => str + byte.toString(16).padStart(2, "0"), "");

const data = new TextEncoder().encode('message to verify');
const signed = await wallet.sign(data, "hex");
await setLogin({ // sends API post call to backend
  variables: {
    publicAddress: walletPublicKey,
    signature: toHexString(signed.signature),
  },
});

// Current WIP for backend code
const ALGORITHM = "ed25519";
const fromHexString = (hexString) =>
  new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
const signature = fromHexString(args.signature);
const nonceUint8 = new TextEncoder().encode('message to verify');
const verified = crypto.verify(
  ALGORITHM,
  nonceUint8,
  `-----BEGIN PUBLIC KEY-----\n${user.publicAddress}\n-----END PUBLIC KEY-----`,
  signature
);
console.log("isVerified: ", verified);

I'm pretty sure I'm going about this the wrong way and there must be an obvious method I'm missing.

As the space matures I expect a verify function or lib will appear to to consume the output of const signed = await wallet.sign(data, "hex");

Something like:

import { VerifyMessage } from '@solana/web3.js';

const verified = VerifyMessage(message, publicKey, signature, 'hex');

But after 3 days of pushing hard I'm starting to hit my limits and my brain is failing. Any help or direction where to look much appreciated

5 Answers

Solved with input from the fantastic Project Serum discord devs. High level solution is to use libs that are also used in the sol-wallet-adapter repo, namely tweetnacl and bs58:

const signatureUint8 = base58.decode(args.signature);
const nonceUint8 = new TextEncoder().encode(user?.nonce);
const pubKeyUint8 = base58.decode(user?.publicAddress);

nacl.sign.detached.verify(nonceUint8, signatureUint8, pubKeyUint8)
// true

I recommend staying in solana-labs trail and use tweetnacl

spl-token-wallet (sollet.io) signs an arbitrary message with nacl.sign.detached(message, this.account.secretKey)

https://github.com/project-serum/spl-token-wallet/blob/9c9f1d48a589218ffe0f54b7d2f3fb29d84f7b78/src/utils/walletProvider/localStorage.js#L65-L67

on the other end, verify is done with nacl.sign.detached.verify

in @solana/web3.js https://github.com/solana-labs/solana/blob/master/web3.js/src/transaction.ts#L560

Use nacl.sign.detached.verify in your backend and you should be good. I also recommend avoiding any data format manipulation, I am not sure what you were trying to do but if you do verify that each step is correct.

For iOS, solana.request will cause error. Use solana.signMessage and base58 encode the signature.

var _signature = ''; 
try {
  signedMessage = await window.solana.request({
    method: "signMessage",
    params: {
      message: encodedMessage
    },
  }); 
  _signature = signedMessage.signature; 
} catch (e) { 
  try {
    signedMessage = await window.solana.signMessage(encodedMessage); 
    _signature = base58.encode(signedMessage.signature); 
  } catch (e1) {
    alert(e1.message);
  }
}
// 
try {
  signIn('credentials',
    {
      publicKey: signedMessage.publicKey,
      signature: _signature,
      callbackUrl: `${window.location.origin}/`
    }
  )
} catch (e) {
  alert(e.message);
}

I needed to convert Uint8Array to string and convert it back to Uint8Array for HTTP communication. I found the toLocaleString method of Uint8Array helpful in this case. It outputs comma-separated integers as a string.

const signedMessage = await window.solana.signMessage(encodedMessage, "utf8");
const signature = signedMessage.signature.toLocaleString();

enter image description here

And then you can convert it back to Uint8Array with the following code.

const signatureUint8 = new Uint8Array(signature.split(",").map(Number));

Edit

The solution above was working on the desktop but when I tried my code inside the Phantom wallet iOS browser it gave an error. I guess the toLocaleString method is not available in that browser. I found a more solid solution to convert Uint8Array to a comma-separated string

Array.apply([], signedMessage.signature).join(",")

Signing and base64 encode:

const data = new TextEncoder().encode(message);
const signature = await wallet.signMessage(data); // Uint8Array
const signatureBase64 = Buffer.from(signature).toString('base64')

Base64 decode and verifying:

const signatureUint8 = new Uint8Array(atob(signature).split('').map(c => c.charCodeAt(0)))
const messageUint8 = new TextEncoder().encode(message)
const pubKeyUint8 = wallet.publicKey.toBytes() // base58.decode(publicKeyAsString)
const result = nacl.sign.detached.verify(messageUint8, signatureUint8, pubKeyUint8) // true or false

Full code example: https://github.com/enginer/solana-message-sign-verify-example

Related