Secp256r1 with Pinocchio
Dean from the Blueshift team has released the first crate that enables Pinocchio compatibility for verifying instructions that execute the Secp256r1
precompile.
This is particularly useful for implementing modern authentication methods like passkeys in Pinocchio programs.
Introduction
The SDK provides clean abstractions through core data structures:
// 33-byte compressed public key (1 byte parity + 32 byte x-coordinate)
pub type Secp256r1Pubkey = [u8; 33];
// 64-byte signature (r,s values)
pub type Secp256r1Signature = [u8; 64];
// Main instruction parser
pub struct Secp256r1Instruction<'a> {
header: Secp256r1InstructionHeader, // Number of signatures
offsets: &'a [Secp256r1SignatureOffsets], // Data location pointers
data: &'a [u8], // Raw instruction data
}
The Secp256r1SignatureOffsets
struct acts as a memory map, containing byte offsets that point to where each component lives within the instruction payload:
pub struct Secp256r1SignatureOffsets {
pub signature_offset: u16,
pub signature_instruction_index: u16,
pub public_key_offset: u16,
pub public_key_instruction_index: u16,
pub message_data_offset: u16,
pub message_data_size: u16,
pub message_instruction_index: u16,
}
In the data, we find the three critical components referenced by the offset structure:
- Publickey: The 33-byte compressed
Secp256r1
public key. When used with modern authentication methods like passkeys, this represents the cryptographic identity of the authenticating device/user. - Signature: The 64-byte
ECDSA
signature (r,s values) generated by the private key. This proves that the holder of the corresponding private key authorized the specific message. - Message Data: The arbitrary bytes that were cryptographically signed. In practice, this contains application-specific data like transaction details, timestamps, or user identifiers that prevent replay attacks and ensure signatures are contextually bound.
As you can see, the publickey is 33 byte long because it uses a compressed point representation; a space-efficient encoding of elliptic curve points.
On Secp256r1, a public key is mathematically a point (x,y) where both coordinates are 32 bytes (64 bytes total).
However, given any x-coordinate, only two possible y-coordinates satisfy the curve equation.
The compressed format stores the 32-byte x-coordinate plus a single parity byte (0x02 for even y, 0x03 for odd y), allowing full point reconstruction with 48% less storage.
Implementation
To verify Secp256r1
signatures, we need two main components:
- The instruction sysvar: This allows us to introspect the
Secp256r1
signature - The
pinocchio-secp256r1-instruction
crate: This provides the tools to deserialize the instruction
The instruction sysvar is already included in the Pinocchio crate, so no additional installation is needed.
However, we need to add the pinocchio-secp256r1-instruction
crate to our Pinocchio program:
cargo add pinocchio-secp256r1-instruction
To implement the verification, we need to:
- Include the Instruction Sysvar program (
Sysvar1nstructions1111111111111111111111111
, which we'll refer to asinstructions
) - Place the
Secp256r1
instruction after our current instruction
Here's how to access and deserialize the instructions:
// Deserialize the instructions sysvar
let instructions: Instructions<Ref<[u8]>> = Instructions::try_from(self.accounts.instructions)?;
// Get the instruction that follows our current one
let ix: IntrospectedInstruction = instructions.get_instruction_relative(1)?;
Next, we deserialize the Secp256r1
instruction:
// Deserialize the Secp256r1 instruction
let secp256r1_ix = Secp256r1Instruction::try_from(&ix)?;
We then perform some security checks.
It's crucial to implement several security checks:
- Authority Check: Ensures that only authorized recipients can receive funds from the PDA that wraps the Secp256r1 public key. This prevents MEV attacks where someone could intercept the transaction, capture the valid signature, and replace the intended recipient.
- Expiry Check: Enforces a time limit on signature validity. Since validated signatures remain valid indefinitely, implementing an expiry timestamp prevents replay attacks.
We perform these checks by putting this data in the message of the signature.
Here's how to implement these security checks:
// Verify the fee payer is authorized
let (receiver, expiry) = secp256r1_ix.get_message_data(0)?.split_at_checked(32).ok_or(ProgramError::InvalidInstructionData)?;
if self.accounts.payer.key().ne(payer) {
return Err(ProgramError::InvalidAccountOwner);
}
// Check signature expiration
let now = Clock::get()?.unix_timestamp;
let expiry = i64::from_le_bytes(expiry.try_into().map_err(|_| ProgramError::InvalidInstructionData)?);
if now > expiry {
return Err(ProgramError::InvalidInstructionData);
}
Finally, we can derive Program Derived Addresses (PDAs) directly from Secp256r1 public keys, creating deterministic account addresses that users can control through modern authentication methods:
// Verify the first signature matches our PDA owner
let signer: Secp256r1Pubkey = *secp256r1_ix.get_signer(0)?;
// Create signer seeds for CPI
let seeds = [
Seed::from(signer[..1].as_ref()),
Seed::from(signer[1..].as_ref()),
];
let signers = [Signer::from(&seeds)];
We need to split the pubkey in 2 different parts because seeds accept 32 bytes as maximum length.