Skip to main content

Try it Live

Run Signature examples in the interactive playground

Validation

Signature validation and canonicalization functions.

isCanonical

Check if ECDSA signature has canonical s-value.

Signature

function isCanonical(signature: BrandedSignature): boolean

Parameters

  • signature - BrandedSignature to check

Returns

  • true - If signature is canonical (s ≤ n/2) or Ed25519
  • false - If signature is non-canonical (s > n/2)

Example

const sig = Signature.fromSecp256k1(r, s, 27);

if (Signature.isCanonical(sig)) {
  console.log('Signature is canonical');
} else {
  console.log('Signature needs normalization');
  const canonical = Signature.normalize(sig);
}

// Ed25519 always canonical
const ed25519Sig = Signature.fromEd25519(bytes);
console.log(Signature.isCanonical(ed25519Sig)); // true

Canonicality Rules

ECDSA (secp256k1, P-256)

A signature is canonical if s ≤ n/2, where n is the curve order. secp256k1:
n = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
n/2 = 7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
P-256:
n = FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
n/2 = 7FFFFFFF800000007FFFFFFFFFFFFFFFDE737D56D38BCF4279DCE5617E3192A8

Why Canonicality Matters

ECDSA signatures have inherent malleability: both (r, s) and (r, -s mod n) are valid for the same message and public key. Security Issues:
  • Transaction malleability attacks
  • Signature duplication
  • Replay attacks
Standards:
  • Bitcoin BIP-62: Require canonical form
  • Ethereum: Enforce low s-value
  • Most modern protocols: Mandate canonicality

Example: Non-Canonical Signature

// High s-value (non-canonical)
const sHigh = new Uint8Array([
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
  0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b,
  0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x40,
]); // s > n/2

const sig = Signature.fromSecp256k1(r, sHigh, 27);
console.log(Signature.isCanonical(sig)); // false

// Normalized (canonical)
const canonical = Signature.normalize(sig);
console.log(Signature.isCanonical(canonical)); // true

normalize

Normalize ECDSA signature to canonical form.

Signature

function normalize(signature: BrandedSignature): BrandedSignature

Parameters

  • signature - BrandedSignature to normalize

Returns

BrandedSignature with canonical s-value (s ≤ n/2) If already canonical: Returns input unchanged If Ed25519: Returns input unchanged (always canonical) If non-canonical: Returns new signature with s = n - s and flipped v

Example

const sig = Signature.fromSecp256k1(r, sHigh, 27);

// Normalize to canonical form
const canonical = Signature.normalize(sig);

console.log(Signature.isCanonical(sig)); // false
console.log(Signature.isCanonical(canonical)); // true

// v flipped when normalizing
console.log(Signature.getV(sig)); // 27
console.log(Signature.getV(canonical)); // 28

// r unchanged
const rOrig = Signature.getR(sig);
const rNorm = Signature.getR(canonical);
console.log(rOrig.every((b, i) => b === rNorm[i])); // true

// s normalized
const sOrig = Signature.getS(sig);
const sNorm = Signature.getS(canonical);
console.log(sOrig.every((b, i) => b === sNorm[i])); // false

Normalization Process

For non-canonical signature (s > n/2):
  1. Calculate s_normalized = n - s
  2. Flip recovery ID: v_new = (v === 27) ? 28 : 27
  3. Return new signature with (r, s_normalized, v_new)
// Before normalization
const sig = Signature.fromSecp256k1(r, sHigh, 27);
// r: [original r]
// s: [high s-value]
// v: 27

// After normalization
const canonical = Signature.normalize(sig);
// r: [original r] (unchanged)
// s: [n - s] (normalized)
// v: 28 (flipped)

Algorithm Details

// Pseudocode for normalization
function normalize(sig: BrandedSignature): BrandedSignature {
  // Ed25519 always canonical
  if (sig.algorithm === 'ed25519') {
    return sig;
  }

  // Already canonical
  if (isCanonical(sig)) {
    return sig;
  }

  // Extract components
  const r = getR(sig);
  const s = getS(sig);

  // Get curve order
  const n = getCurveOrder(sig.algorithm);

  // Calculate s_normalized = n - s (modular subtraction)
  const sNormalized = modularSubtract(n, s);

  // Flip v if present
  const v = sig.v !== undefined
    ? (sig.v === 27 ? 28 : 27)
    : undefined;

  // Return normalized signature
  return fromSecp256k1(r, sNormalized, v);
}

Recovery ID Flip

When normalizing, the recovery ID must flip:
// Original signature with high s
const sig1 = Signature.fromSecp256k1(r, sHigh, 27);

// Normalization flips v
const sig2 = Signature.normalize(sig1);
console.log(Signature.getV(sig2)); // 28

// Double normalization returns to original v
const sig3 = Signature.normalize(sig2);
console.log(Signature.getV(sig3)); // 27

// But s is different (both canonical)
const s2 = Signature.getS(sig2);
const s3 = Signature.getS(sig3);
console.log(s2.every((b, i) => b === s3[i])); // false
Reason: Negating s changes which of the two possible public keys is correct, so recovery ID must flip.

Use Cases

Ethereum Transaction Signing

// Sign transaction
const txHash = keccak256(encodedTx);
const sig = secp256k1.sign(txHash, privateKey);

// Ensure canonical before including in transaction
const canonical = Signature.normalize(sig);

// Only canonical signatures accepted by network
const tx = {
  ...txData,
  r: Signature.getR(canonical),
  s: Signature.getS(canonical),
  v: Signature.getV(canonical),
};

Bitcoin Transaction Validation

// Parse DER signature from transaction
const sig = Signature.fromDER(derBytes, 'secp256k1');

// Bitcoin requires canonical signatures (BIP-62)
if (!Signature.isCanonical(sig)) {
  throw new Error('Non-canonical signature rejected');
}

// Verify signature
const valid = verifySig(sig, txHash, publicKey);

Signature Verification

// Accept both canonical and non-canonical
function verifyFlexible(
  sig: BrandedSignature,
  message: Uint8Array,
  publicKey: Uint8Array
): boolean {
  // Normalize before verification
  const canonical = Signature.normalize(sig);
  return verify(canonical, message, publicKey);
}

// Strict verification (reject non-canonical)
function verifyStrict(
  sig: BrandedSignature,
  message: Uint8Array,
  publicKey: Uint8Array
): boolean {
  if (!Signature.isCanonical(sig)) {
    throw new Error('Non-canonical signature rejected');
  }
  return verify(sig, message, publicKey);
}

API Validation

// Validate signature before storing
function storeSignature(sig: BrandedSignature): void {
  // Normalize to canonical form
  const canonical = Signature.normalize(sig);

  // Store canonical version
  db.signatures.insert({
    r: Signature.getR(canonical),
    s: Signature.getS(canonical),
    v: Signature.getV(canonical),
  });
}

// Retrieve and verify is canonical
function getSignature(id: string): BrandedSignature {
  const data = db.signatures.findById(id);
  const sig = Signature.fromSecp256k1(data.r, data.s, data.v);

  // Assert stored signatures are canonical
  if (!Signature.isCanonical(sig)) {
    throw new Error('Database corruption: non-canonical signature');
  }

  return sig;
}

Validation Patterns

Pre-Normalization Check

// Check before normalizing
if (!Signature.isCanonical(sig)) {
  console.log('Signature needs normalization');
  sig = Signature.normalize(sig);
}

// Now guaranteed canonical
console.assert(Signature.isCanonical(sig));

Enforce Canonical

function ensureCanonical(sig: BrandedSignature): BrandedSignature {
  if (!Signature.isCanonical(sig)) {
    return Signature.normalize(sig);
  }
  return sig;
}

// Always returns canonical signature
const canonical = ensureCanonical(anySig);

Reject Non-Canonical

function requireCanonical(sig: BrandedSignature): void {
  if (!Signature.isCanonical(sig)) {
    throw new NonCanonicalSignatureError(
      'Signature must have low s-value'
    );
  }
}

// Throws if not canonical
requireCanonical(sig);

Security Considerations

Transaction Malleability

// Problem: Both signatures valid for same transaction
const sig1 = Signature.fromSecp256k1(r, s, 27);
const sig2 = Signature.fromSecp256k1(r, sNeg, 28); // s_neg = n - s

// Both verify correctly
verify(sig1, txHash, pubKey); // true
verify(sig2, txHash, pubKey); // true

// But produce different transaction hashes
const tx1Hash = hash(encodeTx(sig1));
const tx2Hash = hash(encodeTx(sig2));
console.log(tx1Hash !== tx2Hash); // true

// Solution: Enforce canonical signatures
const canonical = Signature.normalize(sig1);
// Only canonical signatures allowed

Signature Uniqueness

// Without canonicalization: multiple valid signatures
function getAllValidSignatures(r, s, v): BrandedSignature[] {
  const n = CURVE_ORDER;
  const sNeg = modSubtract(n, s);
  const vFlipped = v === 27 ? 28 : 27;

  return [
    Signature.fromSecp256k1(r, s, v),
    Signature.fromSecp256k1(r, sNeg, vFlipped),
  ];
}

// With canonicalization: unique signature
function getCanonicalSignature(r, s, v): BrandedSignature {
  const sig = Signature.fromSecp256k1(r, s, v);
  return Signature.normalize(sig);
}

Replay Attack Prevention

// Store signature hash to prevent replay
const canonical = Signature.normalize(sig);
const sigHash = keccak256(Signature.toBytes(canonical));

// Check if signature already used
if (await db.usedSignatures.exists(sigHash)) {
  throw new Error('Signature already used');
}

// Mark as used
await db.usedSignatures.insert(sigHash);

Performance

Canonicality Check

// Fast check: O(n) byte comparison
const isCanonical = Signature.isCanonical(sig);

// Typical performance: < 0.001ms for 32-byte comparison

Normalization

// O(n) operation: modular subtraction
const canonical = Signature.normalize(sig);

// If already canonical: O(1) (returns input)
// If needs normalization: O(n) (creates new signature)

// Typical performance: < 0.01ms

Optimization

// Avoid unnecessary normalization
if (Signature.isCanonical(sig)) {
  // Use sig directly
  return sig;
} else {
  // Normalize only if needed
  return Signature.normalize(sig);
}

// Or use normalize (checks internally)
return Signature.normalize(sig); // No-op if canonical

Testing

describe('Signature Validation', () => {
  it('detects canonical signatures', () => {
    const canonical = Signature.fromSecp256k1(r, sLow, 27);
    expect(Signature.isCanonical(canonical)).toBe(true);
  });

  it('detects non-canonical signatures', () => {
    const nonCanonical = Signature.fromSecp256k1(r, sHigh, 27);
    expect(Signature.isCanonical(nonCanonical)).toBe(false);
  });

  it('normalizes non-canonical signatures', () => {
    const sig = Signature.fromSecp256k1(r, sHigh, 27);
    const canonical = Signature.normalize(sig);

    expect(Signature.isCanonical(canonical)).toBe(true);
    expect(Signature.getV(canonical)).toBe(28); // v flipped
  });

  it('preserves canonical signatures', () => {
    const sig = Signature.fromSecp256k1(r, sLow, 27);
    const result = Signature.normalize(sig);

    expect(result).toBe(sig); // Same instance
    expect(Signature.getV(result)).toBe(27); // v unchanged
  });

  it('handles Ed25519 correctly', () => {
    const sig = Signature.fromEd25519(bytes);
    expect(Signature.isCanonical(sig)).toBe(true);
    expect(Signature.normalize(sig)).toBe(sig);
  });
});

See Also