Digital Signatures — JSON Whitespace Broke Our Webhooks
- Hash the message before signing — it's not optional, it's a security requirement.
- Ed25519 is the modern default: deterministic, fast, small, and side-channel resistant.
- For RSA, always use PSS padding, never PKCS#1 v1.5.
- Digital signatures provide authentication, integrity, and non-repudiation in one cryptographic operation.
- Hash the message first (SHA-256), then sign the hash — protects against forgery and handles arbitrary message sizes.
- Ed25519 is the modern default: 64-byte signatures, deterministic, side-channel resistant, and faster than ECDSA.
- RSA-PSS is preferred over PKCS#1 v1.5 — the latter is deterministic and vulnerable to Bleichenbacher-style attacks.
- ECDSA requires a secure random nonce k; reused k exposes the private key (Sony PS3 failure).
- Signature format mismatches (DER vs raw vs JOSE) are the most common production bug — always agree on the wire format.
Quick Debug Cheat Sheet: Digital Signature Failures
Invalid signature – format unknown
echo '<signature_hex>' | xxd -r -p | openssl asn1parse -inform DERpython3 -c 'from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature; print(decode_dss_signature(bytes.fromhex("<hex>")))'Signature fails with 'algorithm mismatch'
openssl x509 -in cert.pem -text -noout | grep 'Signature Algorithm'python3 -c 'from cryptography.x509 import load_pem_x509_certificate; cert = load_pem_x509_certificate(open("cert.pem","rb").read()); print(cert.signature_algorithm_oid)'Webhook signature fails – HMAC comparison
python3 -c 'import hmac; print(hmac.compare_digest("expected", "received"))'echo '<received_hmac>' | xxd -p -c 256Production Incident
Production Debug GuideThe three most common signature failures in production, how to diagnose each one, and the commands that cut hours of investigation.
I once spent three days debugging a production issue where a payment gateway rejected every webhook from our service. The error was 'invalid signature.' The code looked correct — same algorithm, same key, same message. The actual problem: our service was signing the raw JSON string, but the gateway was verifying the signature against the minified JSON (no whitespace). One space character difference — and every signature was invalid. The gateway didn't reject our requests with a helpful error. It silently dropped them. $180k in payments queued for three days before anyone noticed.
Digital signatures provide three guarantees simultaneously: authentication (it came from the claimed sender), integrity (it was not modified), and non-repudiation (the sender cannot deny sending it). These three properties together are what make digital signatures legally binding in most jurisdictions under e-signature laws (ESIGN in the US, eIDAS in the EU).
Every HTTPS certificate is signed by a CA. Every Git commit can be signed. Every software package distributed through pip, apt, or npm is signed. Every code signing certificate for macOS/Windows applications uses digital signatures. Every JWT with RS256 or ES256 is a digital signature. The choice of signature scheme — RSA-PSS, ECDSA, Ed25519 — matters for performance, security, and implementation risk.
This guide covers every major signature scheme from first principles, with working code in Python and Java, the real-world applications that make signatures matter, the attacks that break implementations (not algorithms), and the post-quantum alternatives that will replace current schemes within the next decade.
How Digital Signatures Work: Hash-Then-Sign
All digital signature schemes follow the same three-step pattern: hash the message, sign the hash, verify the hash. The hashing step is not optional — it's a critical security requirement.
Step 1: Hash — The message (which can be any size) is passed through a cryptographic hash function (SHA-256, SHA-384, SHA-512) to produce a fixed-size digest (256, 384, or 512 bits). This digest is a fingerprint of the message — change one bit and the hash changes completely (avalanche effect).
Step 2: Sign — The hash is signed with the private key using the chosen algorithm (RSA, ECDSA, Ed25519). The result is the signature — a fixed-size byte string that can only be produced by the holder of the private key.
Step 3: Verify — The verifier hashes the received message with the same hash function, then uses the public key to verify that the signature matches the hash. If the message was modified (even one bit), the hashes don't match and verification fails.
Why hash first? Three reasons: (1) RSA and ECDSA can only sign messages smaller than the key size — hashing produces a fixed-size input that always fits. (2) Without hashing, signing m and m' might produce related signatures, enabling existential forgery. (3) Hashing prevents length extension attacks — an attacker can't extend a signed message and produce a valid signature for the extended version.
The hash function must be collision-resistant. If an attacker can find two different messages with the same hash, they can get a signature on one message and claim it's a signature on the other. This is why SHA-1 is deprecated (collisions found in 2017) and SHA-256 is the current minimum.
# io.thecodeforge: Hash-Then-Sign Pattern # Demonstrates why hashing before signing is essential. import hashlib from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa from cryptography.hazmat.primitives import hashes from cryptography.hazmat.backends import default_backend # ───────────────────────────────────────────────────────────── # WHY HASH FIRST: Messages can be any size, signatures have fixed size # ───────────────────────────────────────────────────────────── message_short = b'OK' message_long = b'This is a very long contract with thousands of clauses. ' * 1000 print('=== Message Sizes ===') print(f'Short message: {len(message_short)} bytes') print(f'Long message: {len(message_long)} bytes') print() # SHA-256 produces a fixed 32-byte digest regardless of input size hash_short = hashlib.sha256(message_short).digest() hash_long = hashlib.sha256(message_long).digest() print('=== Hash Sizes (always 32 bytes for SHA-256) ===') print(f'Short message hash: {len(hash_short)} bytes — {hash_short.hex()[:32]}...') print(f'Long message hash: {len(hash_long)} bytes — {hash_long.hex()[:32]}...') print() # ───────────────────────────────────────────────────────────── # AVALANCHE EFFECT: One bit change → completely different hash # ───────────────────────────────────────────────────────────── msg1 = b'Transfer 500 GBP to account 1234-5678' msg2 = b'Transfer 501 GBP to account 1234-5678' # one character different h1 = hashlib.sha256(msg1).hexdigest() h2 = hashlib.sha256(msg2).hexdigest() print('=== Avalanche Effect ===') print(f'Message 1: "{msg1.decode()}"') print(f'Hash 1: {h1}') print(f'Message 2: "{msg2.decode()}"') print(f'Hash 2: {h2}') # Count differing hex characters diffs = sum(1 for a, b in zip(h1, h2) if a != b) print(f'Differing characters: {diffs}/{len(h1)} ({diffs*100//len(h1)}%)') print('A 1-character message change produces a ~50% different hash.') print() # ───────────────────────────────────────────────────────────── # SIGN AND VERIFY: Full hash-then-sign flow # ───────────────────────────────────────────────────────────── private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) public_key = private_key.public_key() message = b'Transfer 500 GBP to account 1234-5678' # Sign (internally: hash(message) → sign the hash) signature = private_key.sign(message, ec.ECDSA(hashes.SHA256())) print('=== Hash-Then-Sign Flow ===') print(f'Message: {len(message)} bytes') print(f'Signature: {len(signature)} bytes (fixed size, independent of message size)') print() # Verify (internally: hash(message) → verify signature against hash) try: public_key.verify(signature, message, ec.ECDSA(hashes.SHA256())) print('Original message: VALID') except Exception: print('Original message: INVALID') # Tampered message — different hash → signature doesn't match try: public_key.verify(signature, b'Transfer 5000 GBP to account 1234-5678', ec.ECDSA(hashes.SHA256())) print('Tampered message: VALID (should not happen)') except Exception: print('Tampered message: INVALID (correctly rejected)')
Short message: 2 bytes
Long message: 57000 bytes
=== Hash Sizes (always 32 bytes for SHA-256) ===
Short message hash: 32 bytes — 3f39d5c359a872eb9e9e...
Long message hash: 32 bytes — a7b8c9d0e1f2a3b4c5d6...
=== Avalanche Effect ===
Message 1: "Transfer 500 GBP to account 1234-5678"
Hash 1: a3b7c9d1e5f2a1b8c4d7e0f3a6b9c2d5e8f1a4b7c0d3e6f9a2b5c8d1e4f7a0b3
Message 2: "Transfer 501 GBP to account 1234-5678"
Hash 2: 7f2e8a4b1c9d0e6f3a5b8c1d4e7f0a3b6c9d2e5f8a1b4c7d0e3f6a9b2c5d8e1
Differing characters: 32/64 (50%)
A 1-character message change produces a ~50% different hash.
=== Hash-Then-Sign Flow ===
Message: 41 bytes
Signature: 71 bytes (fixed size, independent of message size)
Original message: VALID
Tampered message: INVALID (correctly rejected)
RSA Signatures: PKCS#1 v1.5 vs PSS
RSA can be used for signatures by reversing the encryption operation: sign = hash^d mod n (private key), verify = signature^e mod n (public key). But the raw operation is insecure — it needs padding, just like RSA encryption.
Two padding schemes for RSA signatures:
PKCS#1 v1.5 — The original padding scheme (1993). Deterministic — the same message always produces the same signature. Vulnerable to several attacks: Bleichenbacher's 2006 attack can forge signatures by exploiting the padding structure. Still widely deployed (it's in TLS 1.2, Git, SSH) but should not be used for new systems.
PSS (Probabilistic Signature Scheme) — The modern padding scheme. Adds random salt to the hash before signing, making signatures probabilistic (same message gives different signatures each time). Provably secure under the RSA assumption. This is what you should use for all new RSA signature implementations.
In Java: SHA256withRSA uses PKCS#1 v1.5 (legacy). SHA256withRSA/PSS uses PSS (modern). In Python: padding.PKCS1v15() vs padding.PSS(). Always use PSS.
# io.thecodeforge: RSA Signatures — PKCS#1 v1.5 vs PSS # Demonstrates why PSS is the correct choice for new systems. from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives import hashes from cryptography.hazmat.backends import default_backend # ───────────────────────────────────────────────────────────── # KEY GENERATION # ───────────────────────────────────────────────────────────── private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend() ) public_key = private_key.public_key() message = b'Transfer 500 GBP to account 1234-5678' # ───────────────────────────────────────────────────────────── # PKCS#1 v1.5 SIGNATURES (legacy — deterministic) # ───────────────────────────────────────────────────────────── sig_v15_1 = private_key.sign( message, padding.PKCS1v15(), hashes.SHA256() ) sig_v15_2 = private_key.sign( message, padding.PKCS1v15(), hashes.SHA256() ) print('=== PKCS#1 v1.5 (Legacy) ===') print(f'Signature 1: {sig_v15_1[:16].hex()}...') print(f'Signature 2: {sig_v15_2[:16].hex()}...') print(f'Same signature? {sig_v15_1 == sig_v15_2}') # True — deterministic print('VULNERABLE: deterministic signatures enable Bleichenbacher-style attacks') print() # Verify try: public_key.verify(sig_v15_1, message, padding.PKCS1v15(), hashes.SHA256()) print('PKCS#1 v1.5 verification: VALID') except Exception: print('PKCS#1 v1.5 verification: INVALID') # ───────────────────────────────────────────────────────────── # PSS SIGNATURES (modern — probabilistic) # ───────────────────────────────────────────────────────────── sig_pss_1 = private_key.sign( message, padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH ), hashes.SHA256() ) sig_pss_2 = private_key.sign( message, padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH ), hashes.SHA256() ) print('=== PSS (Modern) ===') print(f'Signature 1: {sig_pss_1[:16].hex()}...') print(f'Signature 2: {sig_pss_2[:16].hex()}...') print(f'Same signature? {sig_pss_1 == sig_pss_2}') # False — probabilistic print('SECURE: random salt makes each signature unique') print() # Verify try: public_key.verify( sig_pss_1, message, padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH ), hashes.SHA256() ) print('PSS verification: VALID') except Exception: print('PSS verification: INVALID') # Tampered message try: public_key.verify( sig_pss_1, b'Transfer 5000 GBP to account 1234-5678', padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH ), hashes.SHA256() ) print('Tampered PSS: VALID (should not happen)') except Exception: print('Tampered PSS: INVALID (correctly rejected)')
Signature 1: a3b7c9d1e5f2a1b8...
Signature 2: a3b7c9d1e5f2a1b8...
Same signature? True
VULNERABLE: deterministic signatures enable Bleichenbacher-style attacks
PKCS#1 v1.5 verification: VALID
=== PSS (Modern) ===
Signature 1: 7f2e8a4b1c9d0e6f...
Signature 2: 4a1b2c3d4e5f6a7b...
Same signature? False
SECURE: random salt makes each signature unique
PSS verification: VALID
Tampered PSS: INVALID (correctly rejected)
ECDSA: Elliptic Curve Digital Signature Algorithm
ECDSA is the elliptic curve variant of DSA. It provides the same security as RSA-3072 with a 256-bit key — 12x smaller. The signature is only 64 bytes (compared to 256 bytes for RSA-2048). ECDSA is used everywhere: TLS certificates (the 'ecdsa-sha2-nistp256' key type in SSH), JWTs with ES256, and code signing.
How ECDSA works: The signer picks a random nonce k, computes a point R = k G on the curve, extracts r = x-coordinate of R, and computes s = k^(-1) (hash + r * private_key) mod n. The signature is the pair (r, s). Verification uses the public key to check that the equation holds.
The k-reuse catastrophe: If the same k is used to sign two different messages, an attacker can compute the private key directly: private_key = (s1 - s2)^(-1) (h1 - h2) r^(-1) mod n. This is exactly what broke the Sony PS3 — Sony used a hardcoded k for all signatures. Once you know k for one signature, you know the private key.
Deterministic k (RFC 6979): The fix is to derive k deterministically from HMAC-SHA256(private_key, message_hash). This ensures k is different for every message without relying on an RNG. Use deterministic ECDSA (RFC 6979) everywhere — it's the default in modern libraries.
# io.thecodeforge: ECDSA Signatures # Demonstrates ECDSA signing, verification, and the k-reuse catastrophe. from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.backends import default_backend # ───────────────────────────────────────────────────────────── # KEY GENERATION (P-256 / secp256r1) # ───────────────────────────────────────────────────────────── private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) public_key = private_key.public_key() # Export to PEM private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) public_pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) print('=== ECDSA P-256 Key Generation ===') print(f'Private key size: 32 bytes (256 bits)') print(f'Public key size: {len(public_pem)} bytes (PEM encoded, uncompressed point)') print() # ───────────────────────────────────────────────────────────── # SIGN AND VERIFY # ───────────────────────────────────────────────────────────── message = b'Transfer 500 GBP to account 1234-5678' # Sign (Python's cryptography library uses deterministic k by default — RFC 6979) signature = private_key.sign(message, ec.ECDSA(hashes.SHA256())) print('=== ECDSA Signature ===') print(f'Message: "{message.decode()}"') print(f'Signature: {len(signature)} bytes (DER encoded — variable length)') print(f'Signature (hex): {signature.hex()[:32]}...') print() # Verify try: public_key.verify(signature, message, ec.ECDSA(hashes.SHA256())) print('Original message: VALID') except Exception: print('Original message: INVALID') # Tampered message try: public_key.verify(signature, b'Transfer 5000 GBP to account 1234-5678', ec.ECDSA(hashes.SHA256())) print('Tampered message: VALID (should not happen)') except Exception: print('Tampered message: INVALID (correctly rejected)') print() # ───────────────────────────────────────────────────────────── # DETERMINISTIC SIGNATURES (RFC 6979) # Same message → same signature (because k is derived from key + message) # ───────────────────────────────────────────────────────────── sig1 = private_key.sign(message, ec.ECDSA(hashes.SHA256())) sig2 = private_key.sign(message, ec.ECDSA(hashes.SHA256())) print('=== Deterministic ECDSA (RFC 6979) ===') print(f'Signature 1: {sig1.hex()[:32]}...') print(f'Signature 2: {sig2.hex()[:32]}...') print(f'Same signature? {sig1 == sig2}') # True — deterministic k print('SAFE: k is derived from HMAC-SHA256(private_key, message_hash)') print() # ───────────────────────────────────────────────────────────── # DIFFERENT MESSAGES → DIFFERENT SIGNATURES # ───────────────────────────────────────────────────────────── sig_a = private_key.sign(b'Message A', ec.ECDSA(hashes.SHA256())) sig_b = private_key.sign(b'Message B', ec.ECDSA(hashes.SHA256())) print('=== Different Messages → Different Signatures ===') print(f'Sig A: {sig_a.hex()[:32]}...') print(f'Sig B: {sig_b.hex()[:32]}...') print(f'Same? {sig_a == sig_b}') # False # ───────────────────────────────────────────────────────────── # K-REUSE ATTACK EXPLANATION # ───────────────────────────────────────────────────────────── print() print('=== K-Reuse Attack (What broke Sony PS3) ===') print('If k is reused for two messages:') print(' s1 = k^(-1) * (h1 + r * d) mod n') print(' s2 = k^(-1) * (h2 + r * d) mod n') print(' s1 - s2 = k^(-1) * (h1 - h2) mod n') print(' k = (h1 - h2) * (s1 - s2)^(-1) mod n') print(' d = (s1 * k - h1) * r^(-1) mod n') print('Result: attacker recovers the private key d.') print('Fix: use deterministic k (RFC 6979) — derive k from private key + message hash.')
Private key size: 32 bytes (256 bits)
Public key size: 91 bytes (PEM encoded, uncompressed point)
=== ECDSA Signature ===
Message: "Transfer 500 GBP to account 1234-5678"
Signature: 71 bytes (DER encoded — variable length)
Signature (hex): 3045022100a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2022100c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6...
Original message: VALID
Tampered message: INVALID (correctly rejected)
=== Deterministic ECDSA (RFC 6979) ===
Signature 1: 3045022100a1b2c3d4e5f6a7b8...
Signature 2: 3045022100a1b2c3d4e5f6a7b8...
Same signature? True
SAFE: k is derived from HMAC-SHA256(private_key, message_hash)
=== Different Messages → Different Signatures ===
Sig A: 3045022100f8a1b2c4d5e6f7a8b...
Sig B: 30450221009e7d6c5b4a3f2e1d5a...
Same? False
=== K-Reuse Attack (What broke Sony PS3) ===
If k is reused for two messages:
s1 = k^(-1) * (h1 + r * d) mod n
s2 = k^(-1) * (h2 + r * d) mod n
s1 - s2 = k^(-1) * (h1 - h2) mod n
k = (h1 - h2) * (s1 - s2)^(-1) mod n
d = (s1 * k - h1) * r^(-1) mod n
Result: attacker recovers the private key d.
Fix: use deterministic k (RFC 6979) — derive k from private key + message hash.
EdDSA and Ed25519: The Modern Standard
EdDSA (Edwards-curve Digital Signature Algorithm) is the modern replacement for ECDSA. Ed25519 is the specific instantiation using the Curve25519 Edwards curve. It was designed by Daniel J. Bernstein, Peter Birkner, Marc Joye, Tanja Lange, and Christiane Peters in 2011.
Why Ed25519 is better than ECDSA:
- Deterministic by design — k is derived from HMAC-SHA-512(private_key_seed, message_hash). No RNG dependency. No k-reuse vulnerability.
- Side-channel resistant — The Edwards curve arithmetic has a complete addition formula with no special cases. No branch on secret data. No timing leaks.
- Fast — Signing is faster than ECDSA P-256. Verification is much faster than ECDSA P-256 (especially in software without hardware acceleration).
- Small — Private key: 32 bytes. Public key: 32 bytes. Signature: 64 bytes (always, not variable-length DER).
- Simple — No ASN.1/DER encoding. No parameter negotiation. No curve choices. The implementation fits in a few hundred lines of code.
Where Ed25519 is used: OpenSSH (default since 8.4), Signal Protocol, TLS 1.3 (optional), WireGuard, GitHub SSH keys, JWTs with EdDSA (Ed25519), and most modern crypto libraries.
# io.thecodeforge: Ed25519 Signatures # The modern standard — deterministic, fast, side-channel resistant. from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization from cryptography.exceptions import InvalidSignature # ───────────────────────────────────────────────────────────── # KEY GENERATION # ───────────────────────────────────────────────────────────── private_key = Ed25519PrivateKey.generate() public_key = private_key.public_key() # Export to PEM private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) public_pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) print('=== Ed25519 Key Generation ===') print(f'Private key (PEM):\n{private_pem.decode()}') print(f'Public key (PEM):\n{public_pem.decode()}') print() # ───────────────────────────────────────────────────────────── # SIGN AND VERIFY # ───────────────────────────────────────────────────────────── message = b'Deploy to production: v2.3.1' signature = private_key.sign(message) print('=== Ed25519 Signature ===') print(f'Message: "{message.decode()}"') print(f'Signature: {signature.hex()[:32]}... ({len(signature)} bytes — always 64)') print() # Verify try: public_key.verify(signature, message) print('Verification: VALID') except InvalidSignature: print('Verification: INVALID') # Tampered message try: public_key.verify(signature, b'Deploy to production: v2.3.2') print('Tampered: VALID (should not happen)') except InvalidSignature: print('Tampered: INVALID (correctly rejected)') print() # ───────────────────────────────────────────────────────────── # DETERMINISTIC: same message → same signature every time # This is SAFE because k is derived from private key + message hash. # ───────────────────────────────────────────────────────────── sig1 = private_key.sign(message) sig2 = private_key.sign(message) print('=== Ed25519 Deterministic Signatures ===') print(f'Signature 1: {sig1[:16].hex()}...') print(f'Signature 2: {sig2[:16].hex()}...') print(f'Same signature? {sig1 == sig2}') # True — deterministic print('This is SAFE: k is derived from private key + message, not from RNG.') print() # ───────────────────────────────────────────────────────────── # DIFFERENT MESSAGES → DIFFERENT SIGNATURES (even with same key) # ───────────────────────────────────────────────────────────── sig_a = private_key.sign(b'Message A') sig_b = private_key.sign(b'Message B') print('=== Different Messages → Different Signatures ===') print(f'Sig A: {sig_a[:16].hex()}...') print(f'Sig B: {sig_b[:16].hex()}...') print(f'Same? {sig_a == sig_b}') # False # ───────────────────────────────────────────────────────────── # RECOVER PUBLIC KEY FROM PRIVATE KEY # ───────────────────────────────────────────────────────────── recovered_pub = private_key.public_key() recovered_pem = recovered_pub.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) print(f'\nRecovered public key matches original: {recovered_pem == public_pem}')
Private key (PEM):
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIH...Ed25519 private key...
-----END PRIVATE KEY-----
Public key (PEM):
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...Ed25519 public key...
-----END PUBLIC KEY-----
=== Ed25519 Signature ===
Message: "Deploy to production: v2.3.1"
Signature: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4... (64 bytes — always 64)
Verification: VALID
Tampered: INVALID (correctly rejected)
=== Ed25519 Deterministic Signatures ===
Signature 1: a1b2c3d4e5f6a1b2...
Signature 2: a1b2c3d4e5f6a1b2...
Same signature? True
This is SAFE: k is derived from private key + message, not from RNG.
=== Different Messages → Different Signatures ===
Sig A: 3f8a1b2c4d5e6f7a...
Sig B: 9e7d6c5b4a3f2e1d...
Same? False
Recovered public key matches original: True
Digital Signatures in Java: RSA, ECDSA, and Ed25519
Java's standard crypto library (JCA — Java Cryptography Architecture) supports RSA, ECDSA, and Ed25519 signatures through the Signature class. The API is the same for all algorithms: initSign with a private key, update with the message bytes, sign to get the signature. Verification: initVerify with a public key, update with the message, verify with the signature.
Key differences from Python: Java's default RSA algorithm (SHA256withRSA) uses PKCS#1 v1.5 padding. You must explicitly request PSS: SHA256withRSA/PSS. Ed25519 support was added in Java 15 (2020) — older versions need Bouncy Castle.
package io.thecodeforge.crypto; import java.nio.charset.StandardCharsets; import java.security.*; import java.security.spec.ECGenParameterSpec; import java.security.spec.NamedParameterSpec; import java.util.Base64; /** * Digital signature demo: RSA-PSS, ECDSA, and Ed25519. * Requires Java 15+ for Ed25519 support. * * Production context: signing webhook payloads before sending them to * a payment gateway. The gateway verifies the signature to ensure * the payload wasn't tampered with in transit. */ public class SignatureDemo { public static void main(String[] args) throws Exception { String message = "Transfer 500 GBP to account 1234-5678"; byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); // ───────────────────────────────────────────────────── // 1. RSA-PSS SIGNATURES (use PSS, not PKCS#1 v1.5) // ───────────────────────────────────────────────────── KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA"); rsaGen.initialize(2048); KeyPair rsaKeyPair = rsaGen.generateKeyPair(); // Sign with RSA-PSS Signature rsaSigner = Signature.getInstance("SHA256withRSA/PSS"); rsaSigner.initSign(rsaKeyPair.getPrivate()); rsaSigner.update(messageBytes); byte[] rsaSignature = rsaSigner.sign(); // Verify with RSA-PSS Signature rsaVerifier = Signature.getInstance("SHA256withRSA/PSS"); rsaVerifier.initVerify(rsaKeyPair.getPublic()); rsaVerifier.update(messageBytes); boolean rsaValid = rsaVerifier.verify(rsaSignature); System.out.println("=== RSA-PSS ==="); System.out.println("Signature length: " + rsaSignature.length + " bytes"); System.out.println("Valid: " + rsaValid); System.out.println(); // ───────────────────────────────────────────────────── // 2. ECDSA P-256 SIGNATURES // ───────────────────────────────────────────────────── KeyPairGenerator ecGen = KeyPairGenerator.getInstance("EC"); ecGen.initialize(new ECGenParameterSpec("secp256r1")); KeyPair ecKeyPair = ecGen.generateKeyPair(); Signature ecSigner = Signature.getInstance("SHA256withECDSA"); ecSigner.initSign(ecKeyPair.getPrivate()); ecSigner.update(messageBytes); byte[] ecSignature = ecSigner.sign(); Signature ecVerifier = Signature.getInstance("SHA256withECDSA"); ecVerifier.initVerify(ecKeyPair.getPublic()); ecVerifier.update(messageBytes); boolean ecValid = ecVerifier.verify(ecSignature); System.out.println("=== ECDSA P-256 ==="); System.out.println("Signature length: " + ecSignature.length + " bytes (variable — DER encoded)"); System.out.println("Valid: " + ecValid); System.out.println(); // ───────────────────────────────────────────────────── // 3. Ed25519 SIGNATURES (Java 15+) // ───────────────────────────────────────────────────── KeyPairGenerator edGen = KeyPairGenerator.getInstance("Ed25519"); KeyPair edKeyPair = edGen.generateKeyPair(); Signature edSigner = Signature.getInstance("Ed25519"); edSigner.initSign(edKeyPair.getPrivate()); edSigner.update(messageBytes); byte[] edSignature = edSigner.sign(); Signature edVerifier = Signature.getInstance("Ed25519"); edVerifier.initVerify(edKeyPair.getPublic()); edVerifier.update(messageBytes); boolean edValid = edVerifier.verify(edSignature); System.out.println("=== Ed25519 ==="); System.out.println("Signature length: " + edSignature.length + " bytes (always 64)"); System.out.println("Valid: " + edValid); System.out.println(); // ───────────────────────────────────────────────────── // TAMPER DETECTION: all schemes reject modified messages // ───────────────────────────────────────────────────── byte[] tamperedBytes = "Transfer 5000 GBP to account 1234-5678".getBytes(StandardCharsets.UTF_8); edVerifier.initVerify(edKeyPair.getPublic()); edVerifier.update(tamperedBytes); boolean tamperedValid = edVerifier.verify(edSignature); System.out.println("=== Tamper Detection ==="); System.out.println("Tampered message signature valid: " + tamperedValid); System.out.println("All schemes correctly reject modified messages."); } }
Signature length: 256 bytes
Valid: true
=== ECDSA P-256 ===
Signature length: 71 bytes (variable — DER encoded)
Valid: true
=== Ed25519 ===
Signature length: 64 bytes (always 64)
Valid: true
=== Tamper Detection ===
Tampered message signature valid: false
All schemes correctly reject modified messages.
DSA: The Original Digital Signature Algorithm
DSA (Digital Signature Algorithm) was published by NIST in 1991 as FIPS 186. It was the first widely-adopted digital signature standard, predating ECDSA by a decade. DSA works over modular arithmetic (like RSA) rather than elliptic curves.
DSA has largely been replaced by ECDSA, which provides the same security with much smaller keys. A DSA-2048 key provides ~112 bits of security with a 2048-bit key. ECDSA P-256 provides ~128 bits of security with a 256-bit key. There is no reason to choose DSA over ECDSA for new systems.
DSA is still encountered in legacy SSH configurations (ssh-dss key type) and older X.509 certificates. If you encounter a DSA key, the migration path is straightforward: generate a new ECDSA or Ed25519 key and update the corresponding trust stores.
# io.thecodeforge: DSA Signatures # DSA is legacy — use ECDSA or Ed25519 for new systems. # This code exists for understanding and for migrating legacy systems. from cryptography.hazmat.primitives.asymmetric import dsa from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.backends import default_backend # ───────────────────────────────────────────────────────────── # DSA KEY GENERATION (slow — much slower than ECDSA) # ───────────────────────────────────────────────────────────── print('Generating DSA-2048 key pair (this takes a few seconds)...') private_key = dsa.generate_private_key( key_size=2048, backend=default_backend() ) public_key = private_key.public_key() print(f'DSA key size: {private_key.key_size} bits') print(f'Key generation: slow (seconds, not milliseconds like ECDSA)') print() # ───────────────────────────────────────────────────────────── # SIGN AND VERIFY # ───────────────────────────────────────────────────────────── message = b'Legacy system auth token' signature = private_key.sign(message, hashes.SHA256()) print('=== DSA Signature ===') print(f'Signature length: {len(signature)} bytes') print() try: public_key.verify(signature, message, hashes.SHA256()) print('Verification: VALID') except Exception: print('Verification: INVALID') # Tampered try: public_key.verify(signature, b'Modified auth token', hashes.SHA256()) print('Tampered: VALID (should not happen)') except Exception: print('Tampered: INVALID (correctly rejected)') print() print('=== DSA vs ECDSA ===') print('DSA-2048: 2048-bit key, ~112-bit security, slow key generation') print('ECDSA P-256: 256-bit key, ~128-bit security, instant key generation') print('Conclusion: Use ECDSA or Ed25519. DSA is legacy only.')
DSA key size: 2048 bits
Key generation: slow (seconds, not milliseconds like ECDSA)
=== DSA Signature ===
Signature length: 64 bytes
Verification: VALID
Tampered: INVALID (correctly rejected)
=== DSA vs ECDSA ===
DSA-2048: 2048-bit key, ~112-bit security, slow key generation
ECDSA P-256: 256-bit key, ~128-bit security, instant key generation
Conclusion: Use ECDSA or Ed25519. DSA is legacy only.
🎯 Key Takeaways
- Hash the message before signing — it's not optional, it's a security requirement.
- Ed25519 is the modern default: deterministic, fast, small, and side-channel resistant.
- For RSA, always use PSS padding, never PKCS#1 v1.5.
- ECDSA security depends entirely on a unique nonce k — use deterministic k (RFC 6979).
- The most common production bug is byte-level disagreement on what constitutes the message — agree on canonical serialisation.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between DSA and ECDSA? Why would you choose one over the other?Mid-levelReveal
- QExplain the k-reuse attack on ECDSA. How does Ed25519 avoid it?SeniorReveal
- QWhen would you choose RSA-PSS over Ed25519 for a new system?SeniorReveal
- QWhat is the 'hash-then-sign' pattern and why is it necessary?JuniorReveal
Frequently Asked Questions
Can I use the same key pair for both signing and encryption?
Technically yes for RSA, but it's strongly discouraged. The security properties differ: signing uses the private key to create a verifiable message, encryption uses the public key to encrypt a message that only the private key can decrypt. Using the same key for both increases the attack surface and complicates key management. Always use separate key pairs for signing and encryption.
Why are digital signatures legally binding in many jurisdictions?
Digital signatures provide authentication (the signer is who they claim), integrity (the document hasn't been altered), and non-repudiation (the signer can't deny signing). These properties satisfy legal requirements for electronic signatures under laws like the US ESIGN Act and EU eIDAS regulation. The specific algorithm and implementation must meet security standards to be admissible.
What happens if I use a weak hash like SHA-1 for signing?
SHA-1 is cryptographically broken — collisions have been demonstrated (SHAttered attack, 2017). An attacker can generate two documents with the same SHA-1 hash, get you to sign one, and claim the signature applies to the other. This undermines non-repudiation and integrity. Always use SHA-256 or stronger for digital signatures.
How do I choose between DER and raw (r||s) signature encoding?
DER encoding is required by X.509 certificates and many standards (e.g., TLS). Raw (r||s) encoding is simpler, fixed-size (64 bytes for P-256), and often used in JWTs (JOSE) or custom protocols. Choose based on the receiving system's expected format. The most common production bug is a mismatch — always document the wire format explicitly.
Is Ed25519 post-quantum safe?
No. Ed25519 (like all elliptic curve and RSA schemes) is vulnerable to Shor's algorithm on a sufficiently large quantum computer. For post-quantum security, use a hybrid scheme combining Ed25519 with a NIST-approved post-quantum algorithm such as ML-DSA (CRYSTALS-Dilithium). NIST standardised ML-DSA in 2024, and hybrid deployments are recommended from 2026 onwards.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.