diff --git a/.github/workflows/reusable-tests.yaml b/.github/workflows/reusable-tests.yaml index f0f9bc510a..fa32d283f4 100644 --- a/.github/workflows/reusable-tests.yaml +++ b/.github/workflows/reusable-tests.yaml @@ -464,7 +464,8 @@ jobs: path: tests/idl - cmd: cd tests/lazy-account && anchor test path: tests/lazy-account - + - cmd: cd tests/signature-verification && anchor test + path: tests/signature-verification steps: - uses: actions/checkout@v3 - uses: ./.github/actions/setup/ diff --git a/lang/src/error.rs b/lang/src/error.rs index 84de41f89e..71c068cc56 100644 --- a/lang/src/error.rs +++ b/lang/src/error.rs @@ -178,6 +178,26 @@ pub enum ErrorCode { #[msg("A transfer hook extension transfer hook program id constraint was violated")] ConstraintMintTransferHookExtensionProgramId, + // Signature verification errors + /// 2040 - Invalid Ed25519 program id for signature verification + #[msg("Invalid Ed25519 program id for signature verification")] + Ed25519InvalidProgram, + /// 2041 - Invalid Secp256k1 program id for signature verification + #[msg("Invalid Secp256k1 program id for signature verification")] + Secp256k1InvalidProgram, + /// 2042 - Instruction unexpectedly had account metas + #[msg("Instruction unexpectedly had account metas")] + InstructionHasAccounts, + /// 2043 - Message length exceeds allowed maximum + #[msg("Message length exceeds allowed maximum")] + MessageTooLong, + /// 2045 - Invalid Secp256k1 recovery id (must be 0 or 1) + #[msg("Invalid Secp256k1 recovery id")] + InvalidRecoveryId, + /// 2047 - Signature verification failed + #[msg("Signature verification failed")] + SignatureVerificationFailed, + // Require /// 2500 - A require expression was violated #[msg("A require expression was violated")] diff --git a/lang/src/lib.rs b/lang/src/lib.rs index f640694218..7ff0780d0d 100644 --- a/lang/src/lib.rs +++ b/lang/src/lib.rs @@ -43,6 +43,7 @@ pub mod error; pub mod event; #[doc(hidden)] pub mod idl; +pub mod signature_verification; pub mod system_program; mod vec; diff --git a/lang/src/signature_verification/ed25519.rs b/lang/src/signature_verification/ed25519.rs new file mode 100644 index 0000000000..17a11d5baf --- /dev/null +++ b/lang/src/signature_verification/ed25519.rs @@ -0,0 +1,63 @@ +use crate::error::ErrorCode; +use crate::prelude::*; +use crate::solana_program::instruction::Instruction; +use solana_sdk_ids::ed25519_program; + +/// Verifies an Ed25519 signature instruction assuming the signature, public key, +/// and message bytes are embedded directly inside the instruction data (Solana's +/// default encoding). Prefer [`verify_ed25519_ix_with_instruction_index`] when +/// working with custom instructions that point at external instruction data. +pub fn verify_ed25519_ix( + ix: &Instruction, + pubkey: &[u8; 32], + msg: &[u8], + sig: &[u8; 64], +) -> Result<()> { + verify_ed25519_ix_with_instruction_index(ix, u16::MAX, pubkey, msg, sig) +} + +pub fn verify_ed25519_ix_with_instruction_index( + ix: &Instruction, + instruction_index: u16, + pubkey: &[u8; 32], + msg: &[u8], + sig: &[u8; 64], +) -> Result<()> { + require_keys_eq!( + ix.program_id, + ed25519_program::id(), + ErrorCode::Ed25519InvalidProgram + ); + require_eq!(ix.accounts.len(), 0usize, ErrorCode::InstructionHasAccounts); + require!(msg.len() <= u16::MAX as usize, ErrorCode::MessageTooLong); + + const DATA_START: usize = 16; // 2 header + 14 offset bytes + let pubkey_len = pubkey.len() as u16; + let sig_len = sig.len() as u16; + let msg_len = msg.len() as u16; + + let sig_offset: u16 = DATA_START as u16; + let pubkey_offset: u16 = sig_offset + sig_len; + let msg_offset: u16 = pubkey_offset + pubkey_len; + + let mut expected = Vec::with_capacity(DATA_START + sig.len() + pubkey.len() + msg.len()); + + expected.push(1u8); // num signatures + expected.push(0u8); // padding + expected.extend_from_slice(&sig_offset.to_le_bytes()); + expected.extend_from_slice(&instruction_index.to_le_bytes()); + expected.extend_from_slice(&pubkey_offset.to_le_bytes()); + expected.extend_from_slice(&instruction_index.to_le_bytes()); + expected.extend_from_slice(&msg_offset.to_le_bytes()); + expected.extend_from_slice(&msg_len.to_le_bytes()); + expected.extend_from_slice(&instruction_index.to_le_bytes()); + + expected.extend_from_slice(sig); + expected.extend_from_slice(pubkey); + expected.extend_from_slice(msg); + + if expected != ix.data { + return Err(ErrorCode::SignatureVerificationFailed.into()); + } + Ok(()) +} diff --git a/lang/src/signature_verification/mod.rs b/lang/src/signature_verification/mod.rs new file mode 100644 index 0000000000..cd2a9d8d17 --- /dev/null +++ b/lang/src/signature_verification/mod.rs @@ -0,0 +1,50 @@ +use crate::prelude::*; +use crate::solana_program::instruction::Instruction; +use crate::solana_program::sysvar::instructions::{ + load_current_index_checked, load_instruction_at_checked, +}; +use core::convert::TryFrom; + +mod ed25519; +mod secp256k1; + +pub use ed25519::{verify_ed25519_ix, verify_ed25519_ix_with_instruction_index}; +pub use secp256k1::{verify_secp256k1_ix, verify_secp256k1_ix_with_instruction_index}; + +/// Load an instruction from the Instructions sysvar at the given index. +pub fn load_instruction(index: usize, ix_sysvar: &AccountInfo<'_>) -> Result { + let ix = load_instruction_at_checked(index, ix_sysvar) + .map_err(|_| error!(error::ErrorCode::ConstraintRaw))?; + Ok(ix) +} + +/// Loads the instruction currently executing in this transaction and verifies it +/// as an Ed25519 signature instruction. +pub fn verify_current_ed25519_instruction( + ix_sysvar: &AccountInfo<'_>, + pubkey: &[u8; 32], + msg: &[u8], + sig: &[u8; 64], +) -> Result<()> { + let idx = load_current_index_checked(ix_sysvar) + .map_err(|_| error!(error::ErrorCode::ConstraintRaw))?; + let ix = load_instruction(idx as usize, ix_sysvar)?; + verify_ed25519_ix_with_instruction_index(&ix, idx, pubkey, msg, sig) +} + +/// Loads the instruction currently executing in this transaction and verifies it +/// as a Secp256k1 signature instruction. +pub fn verify_current_secp256k1_instruction( + ix_sysvar: &AccountInfo<'_>, + eth_address: &[u8; 20], + msg: &[u8], + sig: &[u8; 64], + recovery_id: u8, +) -> Result<()> { + let idx_u16 = load_current_index_checked(ix_sysvar) + .map_err(|_| error!(error::ErrorCode::ConstraintRaw))?; + let idx_u8 = + u8::try_from(idx_u16).map_err(|_| error!(error::ErrorCode::InvalidNumericConversion))?; + let ix = load_instruction(idx_u16 as usize, ix_sysvar)?; + verify_secp256k1_ix_with_instruction_index(&ix, idx_u8, eth_address, msg, sig, recovery_id) +} diff --git a/lang/src/signature_verification/secp256k1.rs b/lang/src/signature_verification/secp256k1.rs new file mode 100644 index 0000000000..8189f81e80 --- /dev/null +++ b/lang/src/signature_verification/secp256k1.rs @@ -0,0 +1,68 @@ +use crate::error::ErrorCode; +use crate::prelude::*; +use crate::solana_program::instruction::Instruction; +use solana_sdk_ids::secp256k1_program; + +/// Verifies a Secp256k1 instruction created under the assumption that the +/// signature, address, and message bytes all live inside the same instruction +/// (i.e. the signature ix is placed at index `0`). Prefer +/// [`verify_secp256k1_ix_with_instruction_index`] and pass the actual signature +/// instruction index instead of relying on this default. +pub fn verify_secp256k1_ix( + ix: &Instruction, + eth_address: &[u8; 20], + msg: &[u8], + sig: &[u8; 64], + recovery_id: u8, +) -> Result<()> { + verify_secp256k1_ix_with_instruction_index(ix, 0, eth_address, msg, sig, recovery_id) +} + +pub fn verify_secp256k1_ix_with_instruction_index( + ix: &Instruction, + instruction_index: u8, + eth_address: &[u8; 20], + msg: &[u8], + sig: &[u8; 64], + recovery_id: u8, +) -> Result<()> { + require_keys_eq!( + ix.program_id, + secp256k1_program::id(), + ErrorCode::Secp256k1InvalidProgram + ); + require_eq!(ix.accounts.len(), 0usize, ErrorCode::InstructionHasAccounts); + require!(recovery_id <= 1, ErrorCode::InvalidRecoveryId); + require!(msg.len() <= u16::MAX as usize, ErrorCode::MessageTooLong); + + const DATA_START: usize = 12; // 1 header + 11 offset bytes + let eth_len = eth_address.len() as u16; + let sig_len = sig.len() as u16; + let msg_len = msg.len() as u16; + + let eth_offset: u16 = DATA_START as u16; + let sig_offset: u16 = eth_offset + eth_len; + let msg_offset: u16 = sig_offset + sig_len + 1; // +1 for recovery id + + let mut expected = + Vec::with_capacity(DATA_START + eth_address.len() + sig.len() + 1 + msg.len()); + + expected.push(1u8); // num signatures + expected.extend_from_slice(&sig_offset.to_le_bytes()); + expected.push(instruction_index); // sig ix idx + expected.extend_from_slice(ð_offset.to_le_bytes()); + expected.push(instruction_index); // eth ix idx + expected.extend_from_slice(&msg_offset.to_le_bytes()); + expected.extend_from_slice(&msg_len.to_le_bytes()); + expected.push(instruction_index); // msg ix idx + + expected.extend_from_slice(eth_address); + expected.extend_from_slice(sig); + expected.push(recovery_id); + expected.extend_from_slice(msg); + + if expected != ix.data { + return Err(ErrorCode::SignatureVerificationFailed.into()); + } + Ok(()) +} diff --git a/tests/package.json b/tests/package.json index afb9d34a5b..bfd58dabd7 100644 --- a/tests/package.json +++ b/tests/package.json @@ -51,7 +51,8 @@ "cpi-returns", "multiple-suites", "multiple-suites-run-single", - "bpf-upgradeable-state" + "bpf-upgradeable-state", + "signature-verification" ], "dependencies": { "@project-serum/common": "^0.0.1-beta.3", diff --git a/tests/signature-verification/Anchor.toml b/tests/signature-verification/Anchor.toml new file mode 100644 index 0000000000..608470da46 --- /dev/null +++ b/tests/signature-verification/Anchor.toml @@ -0,0 +1,14 @@ +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[features] +seeds = false +resolution = true +skip-lint = false + +[programs.localnet] +signature_verification_test = "9P8zSbNRQkwDrjCmqsHHcU1GTk5npaKYgKHroAkupbLG" + +[scripts] +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.ts" diff --git a/tests/signature-verification/Cargo.toml b/tests/signature-verification/Cargo.toml new file mode 100644 index 0000000000..3c29fab173 --- /dev/null +++ b/tests/signature-verification/Cargo.toml @@ -0,0 +1,7 @@ + +[workspace] +members = ["programs/signature-verification-test"] +resolver = "2" + +[profile.release] +overflow-checks = true diff --git a/tests/signature-verification/package.json b/tests/signature-verification/package.json new file mode 100644 index 0000000000..3fd5b5db57 --- /dev/null +++ b/tests/signature-verification/package.json @@ -0,0 +1,22 @@ +{ + "name": "signature-verification", + "version": "0.31.1", + "license": "(MIT OR Apache-2.0)", + "homepage": "https://github.com/coral-xyz/anchor#readme", + "bugs": { + "url": "https://github.com/coral-xyz/anchor/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/coral-xyz/anchor.git" + }, + "engines": { + "node": ">=17" + }, + "scripts": { + "test": "anchor test" + }, + "dependencies": { + "ethers": "^5.7.2" + } +} diff --git a/tests/signature-verification/programs/signature-verification-test/Cargo.toml b/tests/signature-verification/programs/signature-verification-test/Cargo.toml new file mode 100644 index 0000000000..68bf649926 --- /dev/null +++ b/tests/signature-verification/programs/signature-verification-test/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "signature-verification-test" +version = "0.1.0" +description = "A test program for signature verification" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "signature_verification_test" + +[features] +no-entrypoint = [] +cpi = ["no-entrypoint"] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = { path = "../../../../lang" } diff --git a/tests/signature-verification/programs/signature-verification-test/Xargo.toml b/tests/signature-verification/programs/signature-verification-test/Xargo.toml new file mode 100644 index 0000000000..1744f098ae --- /dev/null +++ b/tests/signature-verification/programs/signature-verification-test/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/tests/signature-verification/programs/signature-verification-test/src/lib.rs b/tests/signature-verification/programs/signature-verification-test/src/lib.rs new file mode 100644 index 0000000000..51265b9172 --- /dev/null +++ b/tests/signature-verification/programs/signature-verification-test/src/lib.rs @@ -0,0 +1,68 @@ +use anchor_lang::prelude::*; +use anchor_lang::signature_verification::{ + load_instruction, verify_ed25519_ix_with_instruction_index, + verify_secp256k1_ix_with_instruction_index, +}; + +declare_id!("9P8zSbNRQkwDrjCmqsHHcU1GTk5npaKYgKHroAkupbLG"); + +#[program] +pub mod signature_verification_test { + use super::*; + + pub fn verify_ed25519_signature( + ctx: Context, + message: Vec, + signature: [u8; 64], + ) -> Result<()> { + let ix = load_instruction(0, &ctx.accounts.ix_sysvar)?; + verify_ed25519_ix_with_instruction_index( + &ix, + u16::MAX, + &ctx.accounts.signer.key().to_bytes(), + &message, + &signature, + )?; + + msg!("Ed25519 signature verified successfully using custom helper!"); + Ok(()) + } + + pub fn verify_secp( + ctx: Context, + message: Vec, + signature: [u8; 64], + recovery_id: u8, + eth_address: [u8; 20], + ) -> Result<()> { + let ix = load_instruction(0, &ctx.accounts.ix_sysvar)?; + verify_secp256k1_ix_with_instruction_index( + &ix, + 0, + ð_address, + &message, + &signature, + recovery_id, + )?; + + msg!("Secp256k1 signature verified successfully using custom helper!"); + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct VerifyEd25519Signature<'info> { + /// CHECK: Signer account + pub signer: AccountInfo<'info>, + /// CHECK: Instructions sysvar account + #[account(address = anchor_lang::solana_program::sysvar::instructions::ID)] + pub ix_sysvar: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct VerifySecp256k1Signature<'info> { + /// CHECK: Instructions sysvar account + #[account(address = anchor_lang::solana_program::sysvar::instructions::ID)] + pub ix_sysvar: AccountInfo<'info>, +} diff --git a/tests/signature-verification/tests/signature-verification-test.ts b/tests/signature-verification/tests/signature-verification-test.ts new file mode 100644 index 0000000000..a0bf85f0e0 --- /dev/null +++ b/tests/signature-verification/tests/signature-verification-test.ts @@ -0,0 +1,372 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Program } from "@coral-xyz/anchor"; +import * as fs from "fs"; +const signatureVerificationTestIDL = JSON.parse( + fs.readFileSync("./target/idl/signature_verification_test.json", "utf8") +); +import { Buffer } from "buffer"; +import { + PublicKey, + Keypair, + Transaction, + SystemProgram, + SYSVAR_INSTRUCTIONS_PUBKEY, + Ed25519Program, + Secp256k1Program, +} from "@solana/web3.js"; +import * as crypto from "crypto"; +import { ethers } from "ethers"; +import * as assert from "assert"; +import { sign } from "@noble/ed25519"; + +describe("signature-verification-test", () => { + const provider = anchor.AnchorProvider.local(undefined, { + commitment: `confirmed`, + }); + + anchor.setProvider(provider); + const program = new anchor.Program( + signatureVerificationTestIDL as anchor.Idl, + provider + ); + + it("Verify Ed25519 signature with valid signature", async () => { + const signer = Keypair.generate(); + const message = Buffer.from( + "Hello, Anchor Signature Verification Test with valid signature!" + ); + const signature = await sign(message, signer.secretKey.slice(0, 32)); + + // Create transaction with just the Ed25519Program instruction + const ed25519Instruction = Ed25519Program.createInstructionWithPublicKey({ + publicKey: signer.publicKey.toBytes(), + message: message, + signature: signature, + }); + + const transaction = new Transaction().add(ed25519Instruction); + + try { + await provider.sendAndConfirm(transaction, []); + console.log("Ed25519 signature verified successfully!"); + } catch (error) { + assert.fail("Valid Ed25519 signature should be verified"); + } + }); + + it("Verify Ed25519 signature with invalid signature", async () => { + const signer = Keypair.generate(); + const message = Buffer.from( + "Hello, Anchor Signature Verification Test with invalid signature!" + ); + // Create a fake signature (all zeros) + const fakeSignature = new Uint8Array(64).fill(0); + + // Create transaction with just the Ed25519Program instruction + const ed25519Instruction = Ed25519Program.createInstructionWithPublicKey({ + publicKey: signer.publicKey.toBytes(), + message: message, + signature: fakeSignature, + }); + + const transaction = new Transaction().add(ed25519Instruction); + + // This should fail + try { + await provider.sendAndConfirm(transaction, []); + assert.fail("Invalid Signature of Ed25519 should not be verified"); + } catch (error) { + console.log("Invalid Signature of Ed25519 is not verified"); + } + }); + + it("Verify Ethereum Secp256k1 signature with valid signature", async () => { + const ethSigner: ethers.Wallet = ethers.Wallet.createRandom(); + const PERSON = { name: "ben", age: 49 }; + + // keccak256(name, age) + const messageHashHex: string = ethers.utils.solidityKeccak256( + ["string", "uint16"], + [PERSON.name, PERSON.age] + ); + const messageHashBytes: Uint8Array = ethers.utils.arrayify(messageHashHex); + + // Sign with Ethereum prefix + const fullSig: string = await ethSigner.signMessage(messageHashBytes); + const fullSigBytes = ethers.utils.arrayify(fullSig); + const signature = fullSigBytes.slice(0, 64); + const recoveryId = fullSigBytes[64] - 27; + + const actualMessage = Buffer.concat([ + Buffer.from("\x19Ethereum Signed Message:\n32"), + Buffer.from(messageHashBytes), + ]); + + // 20-byte ETH address (hex without 0x) + const ethAddressHexNo0x = ethers.utils + .computeAddress(ethSigner.publicKey) + .slice(2); + const ethAddressBytes = Array.from( + ethers.utils.arrayify("0x" + ethAddressHexNo0x) + ) as [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number + ]; + + const verifyIx = await program.methods + .verifySecp( + actualMessage, + Array.from(signature) as [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number + ], + recoveryId, + ethAddressBytes + ) + .accounts({ + ixSysvar: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .instruction(); + + // Secp precompile verification against ETH address + const secpIx = Secp256k1Program.createInstructionWithEthAddress({ + ethAddress: ethAddressHexNo0x, + message: actualMessage, + signature: Uint8Array.from(signature), + recoveryId, + }); + + const tx = new Transaction().add(secpIx).add(verifyIx); + // This should succeed + try { + await provider.sendAndConfirm(tx, []); + console.log("Ethereum Secp256k1 signature verified successfully!"); + } catch (error) { + assert.fail("Valid Signature of Ethereum Secp256k1 should be verified"); + } + }); + + it("Verify Ethereum Secp256k1 signature with invalid signature", async () => { + const ethSigner: ethers.Wallet = ethers.Wallet.createRandom(); + const PERSON = { name: "ben", age: 49 }; + + // keccak256(name, age) + const messageHashHex: string = ethers.utils.solidityKeccak256( + ["string", "uint16"], + [PERSON.name, PERSON.age] + ); + const messageHashBytes: Uint8Array = ethers.utils.arrayify(messageHashHex); + + // Create a fake signature (all zeros) + const fakeSignature = new Uint8Array(64).fill(0); + const fakeRecoveryId = 0; + + const actualMessage = Buffer.concat([ + Buffer.from("\x19Ethereum Signed Message:\n32"), + Buffer.from(messageHashBytes), + ]); + + const ethAddressHexNo0x = ethers.utils + .computeAddress(ethSigner.publicKey) + .slice(2); + const ethAddressBytes = Array.from( + ethers.utils.arrayify("0x" + ethAddressHexNo0x) + ) as [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number + ]; + + const verifyIx = await program.methods + .verifySecp( + actualMessage, + Array.from(fakeSignature) as [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number + ], + fakeRecoveryId, + ethAddressBytes + ) + .accounts({ + ixSysvar: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .instruction(); + const secpIx = Secp256k1Program.createInstructionWithEthAddress({ + ethAddress: ethAddressHexNo0x, + message: actualMessage, + signature: fakeSignature, + recoveryId: fakeRecoveryId, + }); + + const tx = new Transaction().add(secpIx).add(verifyIx); + + // This should fail + try { + await provider.sendAndConfirm(tx, []); + assert.fail("Expected transaction to fail with invalid signature"); + } catch (error) { + console.log( + "Ethereum Secp256k1 verification correctly failed with invalid signature" + ); + } + }); +}); diff --git a/tests/signature-verification/tsconfig.json b/tests/signature-verification/tsconfig.json new file mode 100644 index 0000000000..95e0193a7d --- /dev/null +++ b/tests/signature-verification/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai", "node"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true, + "skipLibCheck": true + } +} \ No newline at end of file