Skip to main content

Secp256k1 Security

Security considerations, attack vectors, and best practices for elliptic curve cryptography with secp256k1.

Critical Warnings

⚠️ Zig Implementation NOT Audited The Zig implementation (src/crypto/secp256k1.zig) is:
  • UNAUDITED - No security review
  • NOT constant-time - Vulnerable to timing attacks
  • Educational only - Do not use in production
For production, use:
  1. TypeScript implementation (@noble/curves) - audited
  2. Hardware wallets (Ledger, Trezor)
  3. EVM precompiles (ecRecover)

Private Key Security

Generation

Use cryptographically secure random: Correct:
// Browser
const privateKey = Bytes32();
crypto.getRandomValues(privateKey);

// Node.js
import crypto from 'crypto';
const privateKey = crypto.randomBytes(32);

// Hardware wallet
// Keys generated in secure hardware, never exported
Never:
// Math.random() is NOT cryptographic
for (let i = 0; i < 32; i++) {
  privateKey[i] = Math.floor(Math.random() * 256); // ❌ INSECURE
}

// Timestamps have low entropy
const timestamp = Date.now();
const hash = keccak256(numberToBytes(timestamp)); // ❌ PREDICTABLE

// User input alone has low entropy
const password = "mypassword123";
const key = keccak256(stringToBytes(password)); // ❌ WEAK
Entropy requirements:
  • Minimum 256 bits (32 bytes) of cryptographic randomness
  • Use OS-provided CSPRNG (crypto.getRandomValues)
  • Hardware RNG preferred (TPM, Secure Enclave)
  • For offline: dice rolls + hashing (256 rolls * 2.58 bits = 660 bits)

Storage

⚠️ Protect keys at rest: Best practices:
  • Hardware wallets: Store in Ledger/Trezor, never export
  • Encrypted keystores: AES-256-GCM with strong KDF (scrypt/argon2)
  • Secure Enclave: iOS/macOS hardware-backed storage
  • HSM: Enterprise hardware security modules
  • Environment isolation: Air-gapped for high-value keys
Avoid:
  • Plain text files
  • Environment variables (leak in logs)
  • Git repositories (permanent history)
  • Clipboard (malware can read)
  • Screenshots (OCR readable)
  • Cloud storage unencrypted
  • Browser localStorage unencrypted

Key Derivation

Use BIP32/BIP39 for backups:
import * as Bip39 from '@tevm/voltaire/crypto/Bip39';
import * as HDWallet from '@tevm/voltaire/crypto/HDWallet';

// Generate mnemonic (12-24 words)
const mnemonic = Bip39.generateMnemonic(256); // 24 words

// Derive master key
const seed = Bip39.mnemonicToSeed(mnemonic);
const masterKey = HDWallet.fromSeed(seed);

// Derive account keys (BIP44)
const accountKey = HDWallet.derivePath(masterKey, "m/44'/60'/0'/0/0");

// Backup: Write down 24 words, never private keys directly

Nonce Security

RFC 6979 Deterministic Nonces

Why deterministic? Random nonce generation has catastrophic failure modes: Nonce reuse leaks private key:
Sign message1 with nonce k: s1 = k^-1 * (e1 + r * privkey)
Sign message2 with nonce k: s2 = k^-1 * (e2 + r * privkey)

Solve for privkey:
k = (e1 - e2) / (s1 - s2)
privkey = (s1*k - e1) / r
Real attacks:
  • PlayStation 3 hack (2010) - Sony reused k=4
  • Bitcoin theft - Bad RNG in Android wallet (2013)
  • Blockchain.info bug (2014) - Weak Java SecureRandom
RFC 6979 prevents this:
k = HMAC_DRBG(key: privkey, data: message_hash)
Benefits:
  • Same (message, key) always produces same nonce
  • No RNG required
  • Deterministic = testable
  • HMAC_DRBG provides cryptographic strength

Implementation Requirements

All implementations MUST:
  • Use RFC 6979 deterministic nonces
  • NEVER allow custom nonce input
  • NEVER reuse nonces across different messages
  • Validate nonce is in range [1, n-1]

Signature Malleability

Problem

ECDSA signatures have inherent malleability:
// Both signatures verify for same (message, publicKey)
const sig1 = { r, s: s, v: 27 };
const sig2 = { r, s: n - s, v: 28 }; // Malleated
Attacks:
  • Transaction replay with modified txHash
  • Smart contract vulnerabilities (signature-based authentication)
  • Blockchain state inconsistency

Solution: Low-s Enforcement

Ethereum enforces s ≤ n/2 (BIP 62, EIP-2):
const HALF_N = SECP256K1_N >> 1n;

if (s > HALF_N) {
  s = SECP256K1_N - s;  // Normalize to low-s
  v ^= 1;  // Flip recovery ID
}
Our implementation:
  • sign() always produces low-s
  • verify() rejects high-s signatures
  • recoverPublicKey() rejects high-s

Side-Channel Attacks

Timing Attacks

Non-constant-time implementations leak secrets via execution time: Vulnerable:
// Branch timing leaks bit values
if (bit == 1) {
  result = result + addend;  // Takes longer
}
Constant-time:
// Same timing regardless of bit value
mask = -(bit & 1);  // 0x00000000 or 0xFFFFFFFF
result = result + (addend & mask);  // Always executes
Attack scenario:
Measure signing time for different messages
→ Deduce private key bits from timing variations
→ After ~1000 signatures, recover full key
Mitigations:
  • Use constant-time libraries (@noble/curves ✅)
  • Avoid conditional branches on secrets
  • Use hardware wallets (constant-time guaranteed)
  • Avoid Zig implementation (⚠️ NOT constant-time)

Power Analysis

Differential Power Analysis (DPA):
  • Measure CPU power consumption during crypto ops
  • Correlate power spikes with bit operations
  • Recover secret keys after many measurements
Simple Power Analysis (SPA):
  • Single power trace reveals operation sequence
  • Identify point additions vs doublings
  • Reconstruct scalar multiplication pattern
Mitigations:
  • Hardware security (HSM, Secure Enclave)
  • Power randomization
  • Constant-time algorithms
  • Blinding techniques

Cache Timing Attacks

Memory access patterns leak information via cache hits/misses: Vulnerable:
// Table lookup leaks index via cache timing
value = precomputed_table[secret_index];
Secure:
// Load entire table (constant-time access)
for (int i = 0; i < table_size; i++) {
  mask = -(i == secret_index);
  value |= precomputed_table[i] & mask;
}
Real attacks:
  • Flush+Reload on AES T-tables
  • Prime+Probe on RSA/ECC

Message Hashing

Always Hash Before Signing

⚠️ Sign hashes, not raw messages: Vulnerable:
// Signing raw message allows chosen-plaintext attacks
const signature = Secp256k1.sign(rawMessage, privateKey);
Secure:
// Hash message first (collision-resistant)
const messageHash = Keccak256.hash(rawMessage);
const signature = Secp256k1.sign(messageHash, privateKey);
Why?
  • ECDSA security requires random-looking messages
  • Raw messages may have structure attackers exploit
  • Hashing provides collision resistance
  • Fixed-length input simplifies validation

Hash Function Requirements

Use collision-resistant hash functions: Approved:
  • Keccak256 (Ethereum standard)
  • SHA-256 (Bitcoin, general use)
  • SHA-3 (NIST standard)
  • Blake2b (high performance)
Deprecated:
  • MD5 (broken - collisions trivial)
  • SHA-1 (broken - collisions practical)

Public Key Validation

Always Validate Public Keys

⚠️ Verify points are on curve:
function isValidPublicKey(pubkey: Uint8Array): boolean {
  if (pubkey.length !== 64) return false;

  const x = bytesToBigInt(pubkey.slice(0, 32));
  const y = bytesToBigInt(pubkey.slice(32, 64));

  // Check y² = x³ + 7 (mod p)
  const p = SECP256K1_P;
  const y2 = (y * y) % p;
  const x3_plus_7 = (x * x * x + 7n) % p;

  return y2 === x3_plus_7;
}
Attacks if unvalidated:
  • Invalid curve attacks (point not on secp256k1)
  • Small subgroup attacks
  • Twist attacks (point on quadratic twist)

Check for Point at Infinity

// Reject point at infinity (identity element)
if (x === 0n && y === 0n) return false;

Ethereum-Specific Considerations

EIP-155 Replay Protection

Problem: Signatures valid on one chain can be replayed on forks. Solution: Include chainId in v value:
// Pre-EIP-155 (vulnerable to replay)
v = recoveryId + 27;

// Post-EIP-155 (replay protected)
v = chainId * 2 + 35 + recoveryId;

// Examples:
// Ethereum mainnet (chainId=1): v = 37 or 38
// Goerli testnet (chainId=5): v = 45 or 46

ecRecover Gotchas

Precompile behavior:
  • Returns zero address on invalid signature (NOT error)
  • Always check return value != 0x0
function verifySigner(bytes32 hash, uint8 v, bytes32 r, bytes32 s, address expected)
  public pure returns (bool)
{
  address signer = ecrecover(hash, v, r, s);

  // ⚠️ Check for zero address (invalid signature)
  if (signer == address(0)) return false;

  return signer == expected;
}

EIP-191 Personal Sign

Prefix prevents signing raw transactions:
// Without prefix: attacker could trick user into signing transaction
const hash = keccak256(transaction);  // ❌ Dangerous

// With prefix: clearly marked as non-transaction message
const prefix = `\x19Ethereum Signed Message:\n${message.length}`;
const hash = keccak256(prefix + message);  // ✅ Safe

Best Practices Summary

DO

✅ Use hardware wallets for high-value keys ✅ Use @noble/curves (audited) for TypeScript ✅ Generate keys with crypto.getRandomValues() ✅ Store encrypted keystores (AES-256-GCM + scrypt) ✅ Validate all inputs (keys, signatures, hashes) ✅ Use RFC 6979 deterministic nonces ✅ Enforce low-s malleability protection ✅ Hash messages before signing ✅ Include EIP-155 chainId in signatures ✅ Use BIP39/BIP32 for backups ✅ Test with official test vectors

DON’T

❌ Use Math.random() for key generation ❌ Reuse nonces across messages ❌ Store private keys unencrypted ❌ Sign raw messages without hashing ❌ Skip public key validation ❌ Use Zig implementation in production (unaudited) ❌ Implement custom crypto (use audited libraries) ❌ Trust user-provided public keys without validation ❌ Ignore signature malleability ❌ Forget EIP-155 replay protection

Security Checklist

  • Keys generated with CSPRNG
  • Keys stored encrypted or in hardware
  • Using audited library (@noble/curves)
  • RFC 6979 deterministic nonces
  • Low-s enforcement enabled
  • Public keys validated (point on curve)
  • Messages hashed before signing
  • EIP-155 chainId in signatures
  • Test vectors passing
  • No custom crypto implementation
  • Side-channel mitigations in place
  • ecRecover zero-address checks