Digital Signatures: RSA and ECDSA
Digital signatures solve a fundamental problem in distributed systems: how do you prove that a message came from who it claims to come from, and that it hasn't been tampered with? Unlike encryption...
Key Insights
- RSA signatures rely on the difficulty of factoring large primes and require 2048+ bit keys, while ECDSA achieves equivalent security with 256-bit keys through elliptic curve mathematics
- ECDSA outperforms RSA in key generation and signing speed, but RSA verification is faster—choose based on your application’s signature-to-verification ratio
- Both algorithms are vulnerable to implementation flaws like weak randomness and timing attacks; use well-audited libraries and never roll your own cryptography
Introduction to Digital Signatures
Digital signatures solve a fundamental problem in distributed systems: how do you prove that a message came from who it claims to come from, and that it hasn’t been tampered with? Unlike encryption (which provides confidentiality), signatures provide authentication and integrity.
You encounter digital signatures constantly. Every time you install signed software, verify a Git commit, authenticate an API request with a JWT, or establish a TLS connection, digital signatures are doing the heavy lifting. They’re the cryptographic equivalent of a wax seal—except mathematically impossible to forge without the private key.
Two algorithms dominate the landscape: RSA, the venerable workhorse dating back to 1977, and ECDSA, the elliptic curve alternative that’s become the modern default. Understanding both is essential for any engineer working with security-sensitive systems.
Cryptographic Foundations
Digital signatures rely on asymmetric cryptography—a key pair where the private key signs and the public key verifies. This separation is crucial: you can freely distribute your public key while keeping the private key secret.
The signing workflow follows a consistent pattern:
- Hash the message to create a fixed-size digest
- Sign the hash with your private key
- Transmit the message and signature
- Recipient hashes the message independently
- Recipient verifies the signature using your public key
Hashing is essential because signing arbitrary-length data directly would be computationally expensive and potentially insecure. The hash creates a fixed-size fingerprint that uniquely represents the original message.
import hashlib
def demonstrate_hashing():
message = b"Transfer $1000 to account 12345"
# SHA-256 produces a 256-bit (32-byte) digest
digest = hashlib.sha256(message).hexdigest()
print(f"Message: {message.decode()}")
print(f"SHA-256: {digest}")
# Even a tiny change produces a completely different hash
tampered = b"Transfer $9000 to account 12345"
tampered_digest = hashlib.sha256(tampered).hexdigest()
print(f"Tampered: {tampered_digest}")
demonstrate_hashing()
The avalanche effect ensures that any modification to the message produces a completely different hash, making tampering detectable.
RSA Signatures Deep Dive
RSA signing works by essentially “encrypting” the hash with the private key. Anyone with the public key can “decrypt” it to recover the original hash, then compare it against their own computation. The security rests on the computational difficulty of factoring the product of two large primes.
Key generation involves selecting two large primes, computing their product (the modulus), and deriving the public and private exponents. Modern implementations require at least 2048-bit keys; 4096-bit provides a larger security margin at the cost of performance.
Padding schemes are critical. Raw RSA signatures are vulnerable to various attacks, so we use standardized padding:
- PKCS#1 v1.5: The legacy standard, still widely used but with known theoretical weaknesses
- PSS (Probabilistic Signature Scheme): The modern recommendation, incorporating randomness for provable security
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.backends import default_backend
def rsa_signature_demo():
# Generate a 2048-bit RSA key pair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
public_key = private_key.public_key()
message = b"Authenticate this API request"
# Sign using PSS padding (recommended)
signature = private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
print(f"Message: {message.decode()}")
print(f"Signature length: {len(signature)} bytes")
print(f"Signature (hex): {signature[:32].hex()}...")
# Verify the signature
try:
public_key.verify(
signature,
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
print("Signature verified successfully")
except Exception as e:
print(f"Verification failed: {e}")
rsa_signature_demo()
ECDSA Signatures Deep Dive
ECDSA (Elliptic Curve Digital Signature Algorithm) achieves the same security goals as RSA but through different mathematics. Instead of factoring large numbers, ECDSA relies on the difficulty of the elliptic curve discrete logarithm problem.
The practical advantage is dramatic key size reduction. A 256-bit ECDSA key provides roughly equivalent security to a 3072-bit RSA key. This translates to smaller certificates, faster key generation, and reduced bandwidth.
Common curves include:
- P-256 (secp256r1): NIST standard, widely supported, good general-purpose choice
- P-384: Higher security margin for sensitive applications
- secp256k1: Bitcoin’s curve, popular in blockchain applications
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
def ecdsa_signature_demo():
# Generate an ECDSA key pair using P-256 curve
private_key = ec.generate_private_key(
ec.SECP256R1(), # P-256 curve
default_backend()
)
public_key = private_key.public_key()
message = b"Authenticate this API request"
# Sign the message
signature = private_key.sign(
message,
ec.ECDSA(hashes.SHA256())
)
print(f"Message: {message.decode()}")
print(f"Signature length: {len(signature)} bytes")
print(f"Signature (hex): {signature.hex()}")
# Verify the signature
try:
public_key.verify(
signature,
message,
ec.ECDSA(hashes.SHA256())
)
print("Signature verified successfully")
except Exception as e:
print(f"Verification failed: {e}")
ecdsa_signature_demo()
Notice the signature is significantly shorter—typically 64-72 bytes for P-256 versus 256 bytes for RSA-2048.
RSA vs ECDSA: Performance and Trade-offs
The choice between RSA and ECDSA involves several trade-offs. Let’s quantify them:
import time
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding
from cryptography.hazmat.backends import default_backend
def benchmark_signatures(iterations=100):
message = b"Benchmark message for signature comparison"
# RSA-2048 benchmark
rsa_private = rsa.generate_private_key(65537, 2048, default_backend())
rsa_public = rsa_private.public_key()
start = time.perf_counter()
for _ in range(iterations):
sig = rsa_private.sign(message, padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
), hashes.SHA256())
rsa_sign_time = (time.perf_counter() - start) / iterations * 1000
start = time.perf_counter()
for _ in range(iterations):
rsa_public.verify(sig, message, padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
), hashes.SHA256())
rsa_verify_time = (time.perf_counter() - start) / iterations * 1000
# ECDSA P-256 benchmark
ec_private = ec.generate_private_key(ec.SECP256R1(), default_backend())
ec_public = ec_private.public_key()
start = time.perf_counter()
for _ in range(iterations):
sig = ec_private.sign(message, ec.ECDSA(hashes.SHA256()))
ec_sign_time = (time.perf_counter() - start) / iterations * 1000
start = time.perf_counter()
for _ in range(iterations):
ec_public.verify(sig, message, ec.ECDSA(hashes.SHA256()))
ec_verify_time = (time.perf_counter() - start) / iterations * 1000
print("Performance Comparison (milliseconds per operation):")
print(f"{'Algorithm':<15} {'Sign':<12} {'Verify':<12}")
print(f"{'RSA-2048':<15} {rsa_sign_time:<12.3f} {rsa_verify_time:<12.3f}")
print(f"{'ECDSA P-256':<15} {ec_sign_time:<12.3f} {ec_verify_time:<12.3f}")
benchmark_signatures()
Typical results show ECDSA signing is 5-10x faster than RSA, but RSA verification is 5-10x faster than ECDSA verification. This asymmetry matters: if you sign once and verify many times (like software distribution), RSA might be preferable. For high-volume signing (like transaction processing), ECDSA wins.
| Metric | RSA-2048 | ECDSA P-256 |
|---|---|---|
| Private key size | 2048 bits | 256 bits |
| Public key size | 2048 bits | 512 bits |
| Signature size | 256 bytes | 64-72 bytes |
| Key generation | Slow | Fast |
| Signing | Slow | Fast |
| Verification | Fast | Slower |
Security Considerations and Best Practices
Both algorithms are secure when implemented correctly. The vulnerabilities lie in implementation details:
Weak randomness is catastrophic. ECDSA in particular requires a unique random value (k) for each signature. Reusing k or using predictable values leaks the private key. The PlayStation 3 code signing key was compromised this way. Use your platform’s cryptographic random number generator—never rand() or similar.
Timing attacks exploit variations in execution time to extract key material. Constant-time implementations are essential. This is why you should use established libraries rather than implementing signature algorithms yourself.
Key management is often the weakest link. Store private keys in hardware security modules (HSMs) for production systems. Use environment variables or secret management services—never commit keys to version control.
Algorithm selection guidelines:
- Default to ECDSA P-256 for new systems—it’s the modern standard
- Use RSA-2048 minimum if you need RSA compatibility; prefer RSA-3072 or RSA-4096 for long-term security
- Consider Ed25519 (EdDSA) for new projects—it’s faster than both and has better security properties
Practical Applications
Here’s how to sign and verify JWTs with both algorithms:
import jwt
import json
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
def jwt_signing_demo():
payload = {
"sub": "user123",
"name": "Jane Developer",
"iat": 1699900000,
"exp": 1699903600
}
# RSA JWT (RS256)
rsa_private = rsa.generate_private_key(65537, 2048, default_backend())
rsa_pem = rsa_private.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()
)
rsa_token = jwt.encode(payload, rsa_pem, algorithm="RS256")
print(f"RS256 JWT length: {len(rsa_token)} chars")
# ECDSA JWT (ES256)
ec_private = ec.generate_private_key(ec.SECP256R1(), default_backend())
ec_pem = ec_private.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()
)
ec_token = jwt.encode(payload, ec_pem, algorithm="ES256")
print(f"ES256 JWT length: {len(ec_token)} chars")
# Verify both
rsa_public_pem = rsa_private.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo
)
ec_public_pem = ec_private.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo
)
decoded_rsa = jwt.decode(rsa_token, rsa_public_pem, algorithms=["RS256"])
decoded_ec = jwt.decode(ec_token, ec_public_pem, algorithms=["ES256"])
print(f"RS256 verified: {decoded_rsa['sub']}")
print(f"ES256 verified: {decoded_ec['sub']}")
jwt_signing_demo()
For Git commit signing, configure your key with git config --global user.signingkey <key-id> and sign commits with git commit -S. For TLS certificates, most certificate authorities now support ECDSA—specify the key type when generating your CSR.
EdDSA/Ed25519 deserves mention as the emerging successor. It’s faster than both RSA and ECDSA, uses a safer curve (Curve25519), and has deterministic signatures that eliminate the random-number vulnerabilities of ECDSA. Adopt it where supported.
Digital signatures are foundational infrastructure. Choose ECDSA for new systems, understand RSA for legacy compatibility, and always use battle-tested libraries. The cryptography is sound—your job is to avoid the implementation pitfalls.