GuidoDipietro / solana-ed25519-secp256k1-sig-verification

On-chain Ed25519 and Secp256k1 signature verification using instruction introspection
43 stars 14 forks source link

[SOLVED]: Verifying NFC chip signature #4

Open 0xlarry opened 1 year ago

0xlarry commented 1 year ago

I am attempting to verify a signature from an NFC chip using this program. Here is an example of the signature it emits:

{
  "input": {
    "keyNo": 1,
    "digest": "bcf83051a4d206c6e43d7eaa4c75429737ac0d5ee08ee68430443bd815e6ac05",
    "message": "010203"
  },
  "signature": {
    "raw": {
      "r": "93137bc7bfeaa86e26c6a9bbd6fb8acdf73ed5fd232cc2be1a0714f583f04d2e",
      "s": "7f5d7c2461daf8649587c3c510fce05a74146cbe79341427065d0d878d154a1b",
      "v": 27
    },
    "der": "304602210093137bc7bfeaa86e26c6a9bbd6fb8acdf73ed5fd232cc2be1a0714f583f04d2e02210080a283db9e25079b6a783c3aef031fa4469a702836148c14b97551054320f726",
    "ether": "0x93137bc7bfeaa86e26c6a9bbd6fb8acdf73ed5fd232cc2be1a0714f583f04d2e7f5d7c2461daf8649587c3c510fce05a74146cbe79341427065d0d878d154a1b1b"
  },
  "publicKey": "046ca7458b4c8c4f9a196094bda5f01ac1e588f6604bc2f7a58ba4d1fa3c3cb9102720bdb43f73972ea3dfc1c6ab8a6cb7d14114765eb76ff0fb2df34a5f7cab56",
  "etherAddress": "0x1aaBF638eC3c4A5C2D5cD14fd460Fee2c364c579"
}

1) is it possible to verify this signature using this program? 2) if so, can you provide a test case that does verify it?

Thank you in advance for the help!

GuidoDipietro commented 1 year ago

Hey there! Apologies for the delay in the answer, I'm sure you have been waiting for it but I was pretty busy with stuff.

Here's the test that validates the signature you provide: (explanation below) You can just add this to the eth_signatures.ts file after the "before" block if you want to test it.

  it("Verifies chip signature", async () => {
    // Defining constants
    const eth_address = "1aaBF638eC3c4A5C2D5cD14fd460Fee2c364c579";

    const actual_message = Buffer.concat([
      Buffer.from("\x19Ethereum Signed Message:\n3"),
      Buffer.from([0x01, 0x02, 0x03]),
    ]);

    const signature = Uint8Array.from(
      Buffer.from(
        "93137bc7bfeaa86e26c6a9bbd6fb8acdf73ed5fd232cc2be1a0714f583f04d2e" +
          "7f5d7c2461daf8649587c3c510fce05a74146cbe79341427065d0d878d154a1b",
        "hex"
      )
    );

    const recoveryId = 27 - 27;

    // Creating transaction with 2 instructions
    let tx = new anchor.web3.Transaction()
      .add(
        // Secp256k1 instruction
        anchor.web3.Secp256k1Program.createInstructionWithEthAddress({
          ethAddress: eth_address,
          message: actual_message,
          signature,
          recoveryId,
        })
      )
      .add(
        // Our instruction
        program.instruction.verifySecp(
          ethers.utils.arrayify("0x" + eth_address),
          Buffer.from(actual_message),
          Buffer.from(signature),
          recoveryId,
          {
            accounts: {
              sender: person.publicKey,
              ixSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
            },
            signers: [person],
          }
        )
      );

    // Sending and exploding if it fails (it won't fail)
    try {
      await anchor.web3.sendAndConfirmTransaction(
        program.provider.connection,
        tx,
        [person]
      );

      // If all goes well, we're good!
    } catch (error) {
      assert.fail(
        `Should not have failed with the following error:\n${error.msg}`
      );
    }
  });

Why like this?

The key part is how the parameters need to be formatted and/or treated, basically. You'll see I defined them like this:

    const eth_address = "1aaBF638eC3c4A5C2D5cD14fd460Fee2c364c579";

    const actual_message = Buffer.concat([
      Buffer.from("\x19Ethereum Signed Message:\n3"),
      ethers.utils.arrayify(Buffer.from([0x01, 0x02, 0x03])),
    ]);

    const signature = Uint8Array.from(
      Buffer.from(
        "93137bc7bfeaa86e26c6a9bbd6fb8acdf73ed5fd232cc2be1a0714f583f04d2e" +
          "7f5d7c2461daf8649587c3c510fce05a74146cbe79341427065d0d878d154a1b",
        "hex"
      )
    );

    const recoveryId = 27 - 27;

And then did some more processing on them on the instruction calls. Let me go step by step:

eth_address

eth_address = "1aaBF638eC3c4A5C2D5cD14fd460Fee2c364c579";

This is not the pubkey. The Ethereum address are just the last 20 bytes of the keccak256 hash of the 32-byte pubkey. More details here. Moreover it is often prefixed with "0x" to indicate that it is an address in hex, but then there's the caveat that some programs trim that 0x so they expect it whereas others don't. It is the case that the Secp256k1 Solana program doesn't expect it but the custom one I did does.

Also, Ethereum addresses have a checksum that changes the capitalization of their letters so some programs also fail if the address is not checksummed. IIRC, the Secp256k1Program in Solana does (but I'm not sure so you should check).

Here, the address in your example is already checksummed and has the "0x" prefix, so I just removed it as my example tests work without the prefix.

actual_message

const actual_message = Buffer.concat([
  Buffer.from("\x19Ethereum Signed Message:\n3"),
  Buffer.from([0x01, 0x02, 0x03]),
]);

Your example says the message is just the hexstring 010203 but this is actually not accurate in the low level.

Ethereum signs messages by first doing some very bizarre stuff on them, namely adding the prefix "\x19Ethereum Signed Message:\n" plus the byte-length of the original message in string format ("3" here), and THEN (generally) even applies a keccak256 hash onto that madness. That is probably the hardest to figure out since you don't expect an innocent 3-byte buffer to be turned into that monster. But that's how it is.

NOTE: The actual original message is usually hashed before too, but here it isn't, and I am not really sure why. I leave the details of that for you to investigate since it is all chip-related implementation. For this reason you see me concatenating the buffer as-is (Buffer.from([0x01, 0x02, 0x03])) and with a length of 3 in the prefix, and not hashing it first and having 32 as length, as in the other examples.

All these details I'm not too familiar with, to be honest. Most of the time including this time I just play around a bit until I figure out a way to make it work. You can check ethers.utils.verifyMessage for more details. This is how I figured it out for your example.

signature

const signature = Uint8Array.from(
  Buffer.from(
    "93137bc7bfeaa86e26c6a9bbd6fb8acdf73ed5fd232cc2be1a0714f583f04d2e" +
      "7f5d7c2461daf8649587c3c510fce05a74146cbe79341427065d0d878d154a1b",
    "hex"
  )
);

The elliptic curve signature has three components: r, s, v. What the Solana Secp256k1 program takes as "signature" and is rather commonplace is just the components r+s concatenated together. This is why it expects a 64-byte buffer as both the r and s are 32 bytes long. We don't use the v here, as that is the recovery byte. If you care about all this stuff you should read a paper about elliptic curve cryptography but you probably just want to make it work. For this reason I'm just telling you, just concatenate both r and s and treat them as hex strings to encode it to whatever is needed. Here a Uint8Array works.

recoveryId

const recoveryId = 27 - 27;

This part is so annoying it took me ages to figure out when I originally wrote this program and played around with Ethereum signatures. The recovery byte in elliptic curve signatures ranges between 0 to 3, 0 and 1 being absurdly more likely than the others. This is just a mathematical thing I have no idea how it works but in essence is useful to recover the signature.

Now, custom implementations shift this value by some constant for reasons that I simply don't know. For this case the shift turns out to be 27. I think this varies from chain to chain but again I don't know. So, in order for the Secp256k1 program to actually be able to verify the signature you need to remove the constant from this term in order to have it in the original format.

If you can understand the details or actually need them (I didn't so I didn't bother too much either) you can find the paper here. The info about this thing, funnily enough, can be found on page 27.

So that's it

That being said you now know how to use the parameters in your chip and hopefully can decode signatures yourself. Keep in mind the program I implemented here just adds an extra layer with custom on-chain logic when these types of signature validations are needed so it's mostly just checking that the previous Secp256k1 Program instruction was built properly and the way you would expect.

If you have any questions feel free to ask me, but do know that I might take a bit to reply generally.

0xlarry commented 1 year ago

Thank you for detailed explanation; this was incredibly helpful.

I appreciate the time you took to clear things up for me!