Skip to main content

BLS Signatures

BLS (Boneh-Lynn-Shacham) signatures are short signatures with efficient aggregation properties, enabling thousands of validator signatures to be compressed into a single 96-byte signature. This is the foundation of Ethereum 2.0’s consensus mechanism.

Overview

BLS signatures leverage the bilinear pairing property of BLS12-381 to enable:
  • Short Signatures: 48 bytes (G1) or 96 bytes (G2)
  • Aggregation: Combine n signatures into one without coordination
  • Batch Verification: Verify multiple signatures in a single pairing check
  • Deterministic: Same message + key always produces same signature

Signature Schemes

Two standard schemes exist, differing in signature/pubkey group placement:

Minimal-Signature-Size (Ethereum Standard)

  • Signatures: G1 points (48 bytes compressed, 96 bytes uncompressed)
  • Public Keys: G2 points (96 bytes compressed, 192 bytes uncompressed)
  • Advantage: Smaller signatures (critical for blockchain bandwidth)
  • Use Case: Ethereum 2.0 validators

Minimal-Pubkey-Size (Alternative)

  • Signatures: G2 points (96 bytes compressed)
  • Public Keys: G1 points (48 bytes compressed)
  • Advantage: Smaller public keys
  • Use Case: Identity systems with many keys
Ethereum uses minimal-signature-size scheme.

Basic Operations

Key Generation

import { randomBytes } from 'crypto';

// Generate private key (32 bytes)
const privateKey = randomBytes(32);

// Derive public key: pubkey = privkey * G2
const g2Generator = new Uint8Array(256); // G2 generator
const scalar = privateKey;
const input = new Uint8Array([...g2Generator, ...scalar]);
const publicKey = new Uint8Array(256);

await bls12_381.g2Mul(input, publicKey);
Security: Private key must be 32 random bytes from cryptographic RNG

Signing

// 1. Hash message to G1 point
const messageHash = hashToG1(message);

// 2. Multiply by private key: sig = privkey * H(msg)
const input = new Uint8Array([...messageHash, ...privateKey]);
const signature = new Uint8Array(128);

await bls12_381.g1Mul(input, signature);

Verification

BLS verification uses pairing check:
e(signature, G2) = e(H(message), publicKey)
Rearranged for single pairing check:
e(signature, G2) * e(-H(message), publicKey) = 1
async function verifyBLSSignature(
  signature: Uint8Array,   // G1 point (128 bytes)
  publicKey: Uint8Array,   // G2 point (256 bytes)
  message: Uint8Array      // Raw message
): Promise<boolean> {
  // Hash message to G1
  const messagePoint = await hashToG1(message);

  // Negate message point
  const negatedMessage = negateG1Point(messagePoint);

  // G2 generator
  const g2Gen = G2_GENERATOR;

  // Pairing check: e(sig, G2) * e(-H(msg), pubkey) = 1
  const pairingInput = new Uint8Array(768);
  pairingInput.set(signature, 0);
  pairingInput.set(g2Gen, 128);
  pairingInput.set(negatedMessage, 384);
  pairingInput.set(publicKey, 512);

  const output = Bytes32();
  await bls12_381.pairing(pairingInput, output);

  return output[31] === 0x01;
}

function negateG1Point(point: Uint8Array): Uint8Array {
  const negated = new Uint8Array(point);
  // Negate y-coordinate: y' = p - y
  const y = negated.slice(64, 128);
  const p = FP_MODULUS;
  const negY = (p - bytesToBigInt(y)) % p;
  negated.set(bigIntToBytes(negY, 64), 64);
  return negated;
}

Hash-to-Curve

Converting messages to G1 points is critical for security:
import { sha256 } from '@tevm/voltaire/crypto';

async function hashToG1(message: Uint8Array): Promise<Uint8Array> {
  // 1. Hash message with domain separation
  const dst = new TextEncoder().encode("BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_");
  const hash1 = sha256(new Uint8Array([...dst, ...message, 0x00]));
  const hash2 = sha256(new Uint8Array([...dst, ...message, 0x01]));

  // 2. Map field elements to G1 points
  const fp1 = hash1; // First 64 bytes (padded Fp element)
  const fp2 = hash2;

  const point1 = new Uint8Array(128);
  const point2 = new Uint8Array(128);

  await bls12_381.mapFpToG1(fp1, point1);
  await bls12_381.mapFpToG1(fp2, point2);

  // 3. Add points (ensures uniform distribution)
  const input = new Uint8Array([...point1, ...point2]);
  const result = new Uint8Array(128);
  await bls12_381.g1Add(input, result);

  return result;
}
RFC 9380: Standard hash-to-curve specification
  • Domain Separation Tag (DST): Prevents cross-protocol attacks
  • Expand-Message-XMD: SHA-256 based expansion
  • SSWU Map: Simplified SWU mapping to curve

Signature Aggregation

Non-Interactive Aggregation

Multiple signatures can be combined without coordination:
async function aggregateSignatures(
  signatures: Uint8Array[]  // Array of G1 signatures
): Promise<Uint8Array> {
  if (signatures.length === 0) {
    throw new Error("No signatures to aggregate");
  }

  let aggregated = signatures[0];

  for (let i = 1; i < signatures.length; i++) {
    const input = new Uint8Array(256);
    input.set(aggregated, 0);
    input.set(signatures[i], 128);

    const output = new Uint8Array(128);
    await bls12_381.g1Add(input, output);
    aggregated = output;
  }

  return aggregated;
}
Properties:
  • Order-independent (addition is commutative)
  • Size constant (always 48 bytes compressed)
  • No interaction required between signers

Aggregate Verification (Same Message)

When all signatures are on the same message:
async function verifyAggregateSignature(
  aggregatedSignature: Uint8Array,
  publicKeys: Uint8Array[],
  message: Uint8Array
): Promise<boolean> {
  // Aggregate public keys
  const aggregatedPubKey = await aggregateG2Points(publicKeys);

  // Verify using standard BLS verification
  return verifyBLSSignature(aggregatedSignature, aggregatedPubKey, message);
}

async function aggregateG2Points(points: Uint8Array[]): Promise<Uint8Array> {
  let aggregated = points[0];

  for (let i = 1; i < points.length; i++) {
    const input = new Uint8Array(512);
    input.set(aggregated, 0);
    input.set(points[i], 256);

    const output = new Uint8Array(256);
    await bls12_381.g2Add(input, output);
    aggregated = output;
  }

  return aggregated;
}
Ethereum Sync Committees: 512 validators sign same block root

Batch Verification (Different Messages)

When signatures are on different messages:
async function batchVerifySignatures(
  signatures: Uint8Array[],
  publicKeys: Uint8Array[],
  messages: Uint8Array[]
): Promise<boolean> {
  const n = signatures.length;

  // Build multi-pairing check:
  // e(sig1, G2) * e(sig2, G2) * ... = e(H(m1), pk1) * e(H(m2), pk2) * ...
  // Equivalent: e(sig1 + sig2 + ..., G2) = e(H(m1), pk1) * e(H(m2), pk2) * ...

  // Aggregate signatures
  const aggSig = await aggregateSignatures(signatures);

  // Build pairing input: pairs of (H(msg_i), pubkey_i)
  const pairingInput = new Uint8Array(384 * (n + 1));

  // First pair: (aggregated signature, G2 generator)
  pairingInput.set(aggSig, 0);
  pairingInput.set(G2_GENERATOR, 128);

  // Remaining pairs: (-H(msg_i), pubkey_i)
  for (let i = 0; i < n; i++) {
    const msgPoint = await hashToG1(messages[i]);
    const negMsgPoint = negateG1Point(msgPoint);

    const offset = 384 * (i + 1);
    pairingInput.set(negMsgPoint, offset);
    pairingInput.set(publicKeys[i], offset + 128);
  }

  const output = Bytes32();
  await bls12_381.pairing(pairingInput, output);

  return output[31] === 0x01;
}
Cost: Single pairing check vs n individual verifications
  • Individual: ~2ms per signature × n
  • Batch: ~2ms + ~23ms per pair (much faster for large n)

Security Considerations

Rogue Key Attacks

Problem: Attacker chooses pubkey_attack = pubkey_target - pubkey_honest
  • Aggregated pubkey = pubkey_honest + pubkey_attack = pubkey_target
  • Attacker can forge signatures for target’s key
Mitigation - Proof of Possession:
// Each validator proves they know the private key
async function generateProofOfPossession(
  privateKey: Uint8Array,
  publicKey: Uint8Array
): Promise<Uint8Array> {
  // Sign the public key itself
  const message = publicKey;
  const messagePoint = await hashToG1(message);

  const input = new Uint8Array([...messagePoint, ...privateKey]);
  const pop = new Uint8Array(128);
  await bls12_381.g1Mul(input, pop);

  return pop;
}

async function verifyProofOfPossession(
  publicKey: Uint8Array,
  pop: Uint8Array
): Promise<boolean> {
  return verifyBLSSignature(pop, publicKey, publicKey);
}
Ethereum Approach: All validators submit proof-of-possession during deposit

Domain Separation

Different signature types must use different DSTs:
const DST_BEACON_BLOCK = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_POP_BEACON_BLOCK_";
const DST_ATTESTATION = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_POP_ATTESTATION_";
const DST_SYNC_COMMITTEE = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_POP_SYNC_COMMITTEE_";
Prevents cross-domain signature reuse attacks.

Point Validation

Always validate deserialized points:
// BLST library performs automatic validation:
// - Point is on curve
// - Point is in correct subgroup
// - Coordinates are in field

try {
  await bls12_381.g1Add(input, output);
} catch (error) {
  // Invalid point detected
  console.error("Point validation failed");
}

Ethereum 2.0 Usage

Validator Signatures

interface BeaconBlockHeader {
  slot: bigint;
  proposerIndex: bigint;
  parentRoot: Uint8Array;
  stateRoot: Uint8Array;
  bodyRoot: Uint8Array;
}

async function signBeaconBlock(
  block: BeaconBlockHeader,
  privateKey: Uint8Array,
  domain: Uint8Array
): Promise<Uint8Array> {
  // 1. Compute signing root
  const blockRoot = hashTreeRoot(block);
  const signingRoot = computeSigningRoot(blockRoot, domain);

  // 2. Hash to G1
  const messagePoint = await hashToG1(signingRoot);

  // 3. Sign
  const input = new Uint8Array([...messagePoint, ...privateKey]);
  const signature = new Uint8Array(128);
  await bls12_381.g1Mul(input, signature);

  return signature;
}

Sync Committee Aggregation

async function aggregateSyncCommitteeSignatures(
  signatures: Uint8Array[],   // 512 validator signatures
  participants: boolean[]      // Which validators participated
): Promise<Uint8Array> {
  const participatingSignatures = signatures.filter((_, i) => participants[i]);
  return aggregateSignatures(participatingSignatures);
}

async function verifySyncCommitteeAggregate(
  aggregatedSignature: Uint8Array,
  publicKeys: Uint8Array[],
  participants: boolean[],
  blockRoot: Uint8Array
): Promise<boolean> {
  const participatingPubKeys = publicKeys.filter((_, i) => participants[i]);
  return verifyAggregateSignature(aggregatedSignature, participatingPubKeys, blockRoot);
}

Performance

Native (BLST):
  • Key generation: ~80 μs
  • Signing: ~100 μs
  • Verification: ~2 ms
  • Aggregation (100 sigs): ~1.5 ms
  • Aggregate verification: ~2 ms (vs 200ms individual)
Optimization Tips:
  • Batch verify when possible
  • Precompute hash-to-curve for known messages
  • Use compressed point formats for storage
  • Cache public key aggregations

Test Vectors

See BLS Test Vectors for official test cases.

References