Skip to main content

Examples

Secp256k1 Verification

Verify ECDSA signatures against public keys to authenticate messages. Signature verification is the cornerstone of Ethereum’s security model - every transaction must pass verification before execution.

Overview

ECDSA verification confirms that a signature was created by the private key corresponding to a given public key. The verifier needs:
  • Signature (r, s, v) - 65 bytes total
  • Message hash - 32 bytes (what was signed)
  • Public key - 64 bytes uncompressed (x || y coordinates)
Verification succeeds if the signature was created by the matching private key, fails otherwise. No secret information is revealed during verification - it’s safe to perform publicly.

API

verify(signature, messageHash, publicKey)

Verify an ECDSA signature against a message hash and public key. Parameters:
  • signature (BrandedSignature) - Signature with r, s, v components
  • messageHash (HashType) - 32-byte hash that was signed
  • publicKey (Uint8Array) - 64-byte uncompressed public key (x || y)
Returns: boolean
  • true - Signature is cryptographically valid
  • false - Signature is invalid or forged
Throws:
  • InvalidPublicKeyError - Public key wrong length or not on curve
  • InvalidSignatureError - Signature components wrong length
Example:
import * as Secp256k1 from '@tevm/voltaire/crypto/Secp256k1';
import { Keccak256 } from '@tevm/voltaire/crypto/Keccak256';

// Sign message
const privateKey = Bytes32();
crypto.getRandomValues(privateKey);
const messageHash = Keccak256.hashString('Verify me!');
const signature = Secp256k1.sign(messageHash, privateKey);

// Verify with public key
const publicKey = Secp256k1.derivePublicKey(privateKey);
const isValid = Secp256k1.verify(signature, messageHash, publicKey);

console.log(isValid); // true

// Verify with wrong public key
const wrongKey = Bytes64();
const invalid = Secp256k1.verify(signature, messageHash, wrongKey);
console.log(invalid); // false

Algorithm Details

ECDSA Verification

  1. Validate inputs:
    • Check 1 ≤ r < n and 1 ≤ s < n (signature component bounds)
    • Verify public key is a valid curve point (satisfies y² = x³ + 7)
  2. Compute message hash scalar: e = hash(message) mod n
  3. Calculate inverse: s_inv = s^-1 mod n
  4. Compute point: R = (e * s_inv) * G + (r * s_inv) * public_key
    • G is the generator point
    • public_key is the signer’s public key point
  5. Verify: Check if R.x mod n == r
    • If equal, signature is valid
    • If not equal, signature is invalid or forged

Why Verification Works

The signature was created as: s = k^-1 * (e + r * private_key) mod n Rearranging: k = s^-1 * (e + r * private_key) mod n Since R = k * G and public_key = private_key * G:
R = k * G
  = s^-1 * (e + r * private_key) * G
  = s^-1 * e * G + s^-1 * r * private_key * G
  = (e * s^-1) * G + (r * s^-1) * public_key
This matches step 4 above. If the signature is valid, R.x == r.

Validation Checks

Signature Component Validation

function isValidSignatureComponents(r: bigint, s: bigint): boolean {
  const n = SECP256K1_N; // Curve order

  // r must be in [1, n-1]
  if (r < 1n || r >= n) return false;

  // s must be in [1, n-1]
  if (s < 1n || s >= n) return false;

  // Ethereum enforces low-s (s ≤ n/2) to prevent malleability
  if (s > n / 2n) return false;

  return true;
}
Invalid components always fail verification.

Public Key Validation

function isValidPublicKey(pubkey: Uint8Array): boolean {
  if (pubkey.length !== 64) return false;

  // Parse x and y coordinates
  const x = bytesToBigInt(pubkey.slice(0, 32));
  const y = bytesToBigInt(pubkey.slice(32, 64));

  // Check point is on curve: y² = x³ + 7 (mod p)
  const p = SECP256K1_P; // Field prime
  const y2 = (y * y) % p;
  const x3_plus_7 = (x * x * x + 7n) % p;

  return y2 === x3_plus_7;
}
Invalid public keys (not on curve) always fail verification.

Security Considerations

Malleability and Low-s

ECDSA signatures have inherent malleability: both (r, s) and (r, n - s) are valid for the same message and key. This can cause issues: Problem:
// Original signature
const sig1 = { r, s: s, v: 27 };
const valid1 = verify(sig1, hash, pubkey); // true

// Malleated signature (different bytes, same validity)
const sig2 = { r, s: CURVE_ORDER - s, v: 28 };
const valid2 = verify(sig2, hash, pubkey); // also true!
Solution: Ethereum enforces low-s (s ≤ n/2):
if (s > CURVE_ORDER / 2n) {
  // Reject high-s signatures
  return false;
}
All signatures created by sign() use low-s. Verification accepts only low-s.

Recovery ID (v) Not Required

The v component is only needed for public key recovery (ecRecover). Standard verification ignores it because the public key is already provided.
// v is ignored during verification (only r and s matter)
const sig1 = { r, s, v: 27 };
const sig2 = { r, s, v: 28 };

// Both verify the same way if public key is provided
verify(sig1, hash, pubkey) === verify(sig2, hash, pubkey);
For recovery-based verification (like Ethereum’s ecRecover), v is critical.

Public Key Format

Secp256k1 public keys can be represented in multiple formats: Uncompressed (65 bytes): 0x04 || x || y
  • Standard format with 0x04 prefix
  • Contains both x and y coordinates
Uncompressed without prefix (64 bytes): x || y
  • Tevm’s internal format (no prefix)
  • Used by our verification API
Compressed (33 bytes): 0x02 || x or 0x03 || x
  • Only x-coordinate + parity bit for y
  • Not directly supported (must decompress first)
Our API expects 64-byte keys (no prefix). If you have prefixed keys:
// Remove 0x04 prefix
if (publicKey.length === 65 && publicKey[0] === 0x04) {
  publicKey = publicKey.slice(1);
}

Test Vectors

Basic Verification

const privateKey = Bytes32();
privateKey[31] = 42;

const messageHash = Keccak256.hashString("test message");
const signature = Secp256k1.sign(messageHash, privateKey);
const publicKey = Secp256k1.derivePublicKey(privateKey);

// Correct public key: verification succeeds
assert(Secp256k1.verify(signature, messageHash, publicKey) === true);

Wrong Public Key

// Different private key
const wrongPrivateKey = Bytes32();
wrongPrivateKey[31] = 99;
const wrongPublicKey = Secp256k1.derivePublicKey(wrongPrivateKey);

// Wrong public key: verification fails
assert(Secp256k1.verify(signature, messageHash, wrongPublicKey) === false);

Wrong Message

const originalHash = Keccak256.hashString("original");
const signature = Secp256k1.sign(originalHash, privateKey);

const differentHash = Keccak256.hashString("different");

// Wrong message hash: verification fails
assert(Secp256k1.verify(signature, differentHash, publicKey) === false);

Malleated Signature

const signature = Secp256k1.sign(messageHash, privateKey);

// Create malleated signature (r, n - s)
const r = bytesToBigInt(signature.r);
const s = bytesToBigInt(signature.s);
const malleatedS = SECP256K1_N - s;

const malleatedSig = {
  r: signature.r,
  s: bigIntToBytes(malleatedS, 32),
  v: signature.v ^ 1, // Flip recovery ID
};

// Malleated signature (high-s): verification fails
assert(Secp256k1.verify(malleatedSig, messageHash, publicKey) === false);

Invalid Signature Components

// r = 0 (invalid)
const invalidR = {
  r: Bytes32(), // All zeros
  s: signature.s,
  v: 27,
};
assert(Secp256k1.verify(invalidR, messageHash, publicKey) === false);

// s >= n (invalid)
const invalidS = {
  r: signature.r,
  s: Bytes32().fill(0xff), // All 0xff > n
  v: 27,
};
assert(Secp256k1.verify(invalidS, messageHash, publicKey) === false);

Performance

Verification Cost

ECDSA verification is computationally expensive:
  1. Modular inversion - s^-1 mod n (expensive)
  2. Two scalar multiplications - u1 * G + u2 * public_key
  3. Point operations - Elliptic curve point addition
Typical verification time:
  • TypeScript (@noble/curves): ~1-2ms per signature
  • Zig (native): ~0.5-1ms per signature
  • WASM (portable): ~2-4ms per signature
For batch verification of multiple signatures, use optimized batch algorithms (not currently exposed in API).

EVM Precompile

Ethereum provides ecRecover precompile (address 0x01) for on-chain verification:
  • Gas cost: 3000 gas
  • Input: 128 bytes (hash, v, r, s)
  • Output: 32 bytes (recovered address, zero-padded)
For smart contracts, use ecrecover() built-in instead of implementing verification in Solidity.

Implementation Notes

TypeScript

Uses @noble/curves/secp256k1:
  • Constant-time operations (side-channel resistant)
  • Validates all inputs (signature components, public keys)
  • Enforces low-s malleability protection
  • ~20KB minified, tree-shakeable

Zig

Custom implementation:
  • ⚠️ UNAUDITED - Not security reviewed
  • ⚠️ NOT constant-time - Timing attack vulnerable
  • Basic validation only
  • Educational purposes only