Overview
Digital signatures provide cryptographic proof of authenticity and integrity for digital messages and documents. A digital signature binds a message to a specific private key holder, creating verifiable evidence that the message originated from that entity and has not been altered since signing.
The signing process applies a cryptographic hash function to the message, then encrypts the hash with the sender's private key. Recipients verify the signature by decrypting it with the sender's public key and comparing the result to their own hash of the message. Matching hashes confirm both the sender's identity and message integrity.
Digital signatures form the foundation of modern secure communication, appearing in TLS/SSL certificates, software distribution, blockchain transactions, email encryption, and document authentication. Unlike handwritten signatures, digital signatures provide mathematical proof of authenticity that cannot be forged without access to the private key.
require 'openssl'
# Generate key pair
private_key = OpenSSL::PKey::RSA.new(2048)
public_key = private_key.public_key
# Sign a message
message = "Transfer $1000 to account 12345"
signature = private_key.sign(OpenSSL::Digest.new('SHA256'), message)
# Verify signature
valid = public_key.verify(OpenSSL::Digest.new('SHA256'), signature, message)
# => true
This example demonstrates the core workflow: key generation, signing, and verification. The signature proves that someone with access to the private key created it for this specific message.
Key Principles
Digital signature systems depend on asymmetric cryptography, where a key pair consists of a private key for signing and a public key for verification. The mathematical relationship between these keys ensures that signatures created with the private key can be verified with the public key, but the public key cannot be used to forge signatures.
The signing process involves two cryptographic operations. First, the system applies a cryptographic hash function to the input message, producing a fixed-size digest regardless of message length. Hash functions like SHA-256 or SHA-512 create unique fingerprints where even single-bit changes in the input produce completely different outputs. Second, the system encrypts this digest using the private key, creating the signature. The combination of hashing and encryption ensures that signatures remain computationally infeasible to forge.
Verification reverses the process. The verifier decrypts the signature using the public key, recovering the original hash digest. The verifier independently computes the hash of the received message and compares it to the decrypted digest. If they match, the signature is valid. This comparison confirms two properties: the message has not been altered (integrity) and the signature came from the private key holder (authenticity).
Different signature algorithms offer varying security levels and performance characteristics. RSA signatures use modular exponentiation with large integers, typically 2048 or 4096 bits. ECDSA (Elliptic Curve Digital Signature Algorithm) achieves equivalent security with smaller key sizes, offering 256-bit keys with security comparable to 3072-bit RSA keys. EdDSA variants like Ed25519 provide both security and performance benefits through careful algorithm design.
The security of digital signatures rests on the computational difficulty of deriving private keys from public keys. RSA security depends on the integer factorization problem, while ECDSA and EdDSA rely on the elliptic curve discrete logarithm problem. Current quantum computers cannot break these systems, but future quantum computers could potentially compromise them, driving research into post-quantum signature algorithms.
Non-repudiation represents a critical property of digital signatures. Once a message is signed, the signer cannot credibly deny creating the signature without claiming their private key was compromised. This property makes digital signatures legally binding in many jurisdictions and useful for contracts, financial transactions, and audit trails.
Signature schemes also incorporate randomness during signing. Deterministic schemes like Ed25519 derive randomness from the message and key, while schemes like ECDSA require high-quality random numbers for each signature. Poor randomness in ECDSA can leak private key information, as demonstrated by attacks on PlayStation 3 and blockchain implementations that reused random values.
Ruby Implementation
Ruby provides digital signature capabilities through the OpenSSL library, which wraps the OpenSSL C library. The OpenSSL::PKey module contains classes for different key types, including RSA, DSA, and elliptic curve keys.
RSA Signatures
RSA signatures remain the most widely deployed signature algorithm. Ruby's OpenSSL bindings support RSA key generation, signing, and verification:
require 'openssl'
# Generate RSA key pair
rsa_key = OpenSSL::PKey::RSA.new(2048)
# Extract public key for distribution
public_key = rsa_key.public_key
# Sign data
data = "Critical system configuration change"
digest = OpenSSL::Digest.new('SHA256')
signature = rsa_key.sign(digest, data)
puts "Signature length: #{signature.bytesize} bytes"
# => Signature length: 256 bytes
# Verify signature
valid = public_key.verify(digest, signature, data)
puts "Signature valid: #{valid}"
# => Signature valid: true
# Tampering detection
tampered_data = data + " malicious addition"
still_valid = public_key.verify(digest, signature, tampered_data)
puts "Tampered signature valid: #{still_valid}"
# => Tampered signature valid: false
The signature size equals the key size in bytes (256 bytes for 2048-bit keys). RSA signing and verification involve modular exponentiation operations that scale with key size, making larger keys slower but more secure.
Elliptic Curve Signatures
Elliptic curve cryptography offers smaller signatures and faster operations. Ruby supports ECDSA through the EC key class:
# Generate EC key pair using P-256 curve
ec_key = OpenSSL::PKey::EC.generate('prime256v1')
# Sign with ECDSA
data = "Blockchain transaction data"
digest = OpenSSL::Digest.new('SHA256')
signature = ec_key.sign(digest, data)
puts "ECDSA signature length: #{signature.bytesize} bytes"
# => ECDSA signature length: 70-72 bytes (varies)
# Verify ECDSA signature
public_key = OpenSSL::PKey::EC.new(ec_key.group)
public_key.public_key = ec_key.public_key
valid = public_key.verify(digest, signature, data)
puts "ECDSA signature valid: #{valid}"
# => ECDSA signature valid: true
ECDSA signatures are significantly smaller than RSA signatures. A P-256 signature produces roughly 70 bytes compared to 256 bytes for RSA-2048, reducing bandwidth and storage requirements.
Key Serialization
Applications must serialize keys for storage and transmission. Ruby supports multiple key formats:
# PEM format (text-based, widely compatible)
private_pem = rsa_key.to_pem
public_pem = public_key.to_pem
# DER format (binary, more compact)
private_der = rsa_key.to_der
public_der = public_key.to_der
# Load keys from serialized format
loaded_private = OpenSSL::PKey::RSA.new(private_pem)
loaded_public = OpenSSL::PKey::RSA.new(public_pem)
# Password-protected private key
cipher = OpenSSL::Cipher.new('AES-256-CBC')
encrypted_pem = rsa_key.to_pem(cipher, 'secret_password')
# Load encrypted key
protected_key = OpenSSL::PKey::RSA.new(encrypted_pem, 'secret_password')
PEM format encodes binary key data as Base64 text with header and footer lines, making it suitable for embedding in configuration files or transmitting through text-only channels. DER format stores the binary ASN.1 representation directly, reducing file size but requiring binary-safe handling.
Signing Files
Real applications often sign files rather than in-memory strings. Ruby's OpenSSL integration handles large files efficiently:
def sign_file(filename, private_key)
digest = OpenSSL::Digest.new('SHA256')
File.open(filename, 'rb') do |file|
# Stream file contents to avoid loading entire file
while chunk = file.read(8192)
digest.update(chunk)
end
end
# Sign the computed digest
private_key.sign_raw('RSA', digest.digest)
end
def verify_file_signature(filename, signature, public_key)
digest = OpenSSL::Digest.new('SHA256')
File.open(filename, 'rb') do |file|
while chunk = file.read(8192)
digest.update(chunk)
end
end
# Verify signature against computed digest
public_key.verify_raw('RSA', signature, digest.digest)
end
# Usage
signature = sign_file('large_dataset.csv', private_key)
File.write('large_dataset.csv.sig', signature)
# Later verification
signature = File.read('large_dataset.csv.sig')
valid = verify_file_signature('large_dataset.csv', signature, public_key)
This approach processes files in chunks, computing the hash incrementally without loading the entire file into memory. The pattern works for files of any size.
Multiple Signatures
Some applications require multiple signatures on the same document, such as contracts requiring approval from multiple parties:
class MultiSignatureDocument
attr_reader :content, :signatures
def initialize(content)
@content = content
@signatures = {}
end
def sign(signer_id, private_key)
digest = OpenSSL::Digest.new('SHA256')
signature = private_key.sign(digest, @content)
@signatures[signer_id] = {
signature: signature,
public_key: private_key.public_key.to_pem
}
end
def verify(signer_id)
return false unless @signatures[signer_id]
sig_data = @signatures[signer_id]
public_key = OpenSSL::PKey::RSA.new(sig_data[:public_key])
digest = OpenSSL::Digest.new('SHA256')
public_key.verify(digest, sig_data[:signature], @content)
end
def verify_all
@signatures.keys.all? { |id| verify(id) }
end
end
# Usage
doc = MultiSignatureDocument.new("Contract terms and conditions")
doc.sign("alice", alice_key)
doc.sign("bob", bob_key)
puts "All signatures valid: #{doc.verify_all}"
This pattern maintains separate signatures for each signer, allowing independent verification of each party's approval.
Practical Examples
Software Package Signing
Software distributors sign packages to prove authenticity and prevent tampering. This example demonstrates a complete package signing workflow:
require 'openssl'
require 'json'
require 'digest'
class PackageSigner
def initialize(private_key_path, public_key_path)
@private_key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
@public_key = OpenSSL::PKey::RSA.new(File.read(public_key_path))
end
def sign_package(package_path)
# Compute package hash
package_hash = Digest::SHA256.file(package_path).hexdigest
# Create signature manifest
manifest = {
package: File.basename(package_path),
hash: package_hash,
hash_algorithm: 'SHA256',
timestamp: Time.now.utc.iso8601,
signer: 'Release Engineering'
}
# Sign the manifest
manifest_json = JSON.generate(manifest)
digest = OpenSSL::Digest.new('SHA256')
signature = @private_key.sign(digest, manifest_json)
# Create signed manifest
signed_manifest = manifest.merge({
signature: Base64.strict_encode64(signature)
})
# Write signature file
signature_path = "#{package_path}.sig"
File.write(signature_path, JSON.pretty_generate(signed_manifest))
signature_path
end
def verify_package(package_path, signature_path)
# Load signature manifest
signed_manifest = JSON.parse(File.read(signature_path))
signature = Base64.strict_decode64(signed_manifest['signature'])
# Verify package hash matches manifest
actual_hash = Digest::SHA256.file(package_path).hexdigest
return false unless actual_hash == signed_manifest['hash']
# Reconstruct signed content
manifest = signed_manifest.reject { |k, _| k == 'signature' }
manifest_json = JSON.generate(manifest)
# Verify signature
digest = OpenSSL::Digest.new('SHA256')
@public_key.verify(digest, signature, manifest_json)
end
end
# Usage
signer = PackageSigner.new('release_private.pem', 'release_public.pem')
# Developer signs package
signer.sign_package('myapp-1.2.3.tar.gz')
# User verifies package before installation
if signer.verify_package('myapp-1.2.3.tar.gz', 'myapp-1.2.3.tar.gz.sig')
puts "Package signature valid - safe to install"
else
puts "WARNING: Invalid signature - do not install"
end
This implementation separates the file hash from the signature, allowing efficient verification without re-signing large files. The manifest includes metadata like timestamp and signer identity for audit purposes.
API Request Signing
APIs often require signed requests to authenticate clients and prevent replay attacks:
class APIRequestSigner
def initialize(client_id, private_key)
@client_id = client_id
@private_key = private_key
end
def sign_request(method, path, body, timestamp = Time.now.to_i)
# Construct canonical request
canonical = [
method.upcase,
path,
timestamp.to_s,
body || ''
].join("\n")
# Sign canonical request
digest = OpenSSL::Digest.new('SHA256')
signature = @private_key.sign(digest, canonical)
{
'X-Client-ID' => @client_id,
'X-Timestamp' => timestamp.to_s,
'X-Signature' => Base64.strict_encode64(signature)
}
end
end
class APIRequestVerifier
def initialize(client_registry)
@client_registry = client_registry
end
def verify_request(method, path, body, headers)
client_id = headers['X-Client-ID']
timestamp = headers['X-Timestamp'].to_i
signature = Base64.strict_decode64(headers['X-Signature'])
# Check timestamp freshness (prevent replay attacks)
age = Time.now.to_i - timestamp
return false if age.abs > 300 # 5 minute window
# Get client's public key
public_key = @client_registry.get_public_key(client_id)
return false unless public_key
# Reconstruct canonical request
canonical = [
method.upcase,
path,
timestamp.to_s,
body || ''
].join("\n")
# Verify signature
digest = OpenSSL::Digest.new('SHA256')
public_key.verify(digest, signature, canonical)
end
end
# Usage
client = APIRequestSigner.new('client-123', client_private_key)
headers = client.sign_request('POST', '/api/transfer', '{"amount":1000}')
# Server side
verifier = APIRequestVerifier.new(client_registry)
valid = verifier.verify_request('POST', '/api/transfer', '{"amount":1000}', headers)
The timestamp inclusion prevents replay attacks where attackers capture and retransmit valid requests. The five-minute window balances security against clock skew between client and server.
Code Signing for Deployment
Organizations sign deployment artifacts to ensure only authorized code runs in production:
class DeploymentSigner
APPROVED_ENVIRONMENTS = ['production', 'staging']
def initialize(key_path)
@private_key = OpenSSL::PKey::RSA.new(File.read(key_path))
end
def create_deployment_signature(artifact_path, environment, version)
raise "Invalid environment" unless APPROVED_ENVIRONMENTS.include?(environment)
# Compute artifact hash
artifact_hash = Digest::SHA256.file(artifact_path).hexdigest
# Create deployment metadata
metadata = {
artifact: File.basename(artifact_path),
artifact_hash: artifact_hash,
environment: environment,
version: version,
signed_at: Time.now.utc.iso8601,
signed_by: ENV['USER'] || 'automated'
}
# Sign metadata
metadata_json = JSON.generate(metadata)
digest = OpenSSL::Digest.new('SHA256')
signature = @private_key.sign(digest, metadata_json)
{
metadata: metadata,
signature: Base64.strict_encode64(signature)
}
end
end
class DeploymentVerifier
def initialize(public_key_path)
@public_key = OpenSSL::PKey::RSA.new(File.read(public_key_path))
end
def verify_deployment(artifact_path, deployment_signature)
metadata = deployment_signature[:metadata]
signature = Base64.strict_decode64(deployment_signature[:signature])
# Verify artifact hasn't changed
actual_hash = Digest::SHA256.file(artifact_path).hexdigest
unless actual_hash == metadata[:artifact_hash]
return { valid: false, reason: 'Artifact hash mismatch' }
end
# Verify signature
metadata_json = JSON.generate(metadata)
digest = OpenSSL::Digest.new('SHA256')
valid = @public_key.verify(digest, signature, metadata_json)
unless valid
return { valid: false, reason: 'Invalid signature' }
end
# Check signature age
signed_at = Time.parse(metadata[:signed_at])
age_hours = (Time.now - signed_at) / 3600
if age_hours > 24
return { valid: false, reason: "Signature expired (#{age_hours.round}h old)" }
end
{ valid: true, metadata: metadata }
end
end
This deployment workflow prevents unauthorized code changes and ensures traceability. The signature includes environment restrictions, preventing production-signed artifacts from being deployed to development environments.
Security Implications
Digital signature implementations face numerous security challenges beyond the mathematical strength of the underlying algorithms. Implementation flaws, key management failures, and protocol weaknesses can compromise signature security even when using strong cryptographic primitives.
Private Key Protection
Private key compromise completely breaks signature security. Attackers with private key access can forge arbitrary signatures, impersonate the key holder, and sign malicious content. Key protection requires multiple defensive layers:
# Secure key generation with proper randomness
private_key = OpenSSL::PKey::RSA.new(2048)
# Always encrypt private keys at rest
cipher = OpenSSL::Cipher.new('AES-256-CBC')
encrypted_pem = private_key.to_pem(cipher, ENV['KEY_PASSWORD'])
File.write('private_key.pem', encrypted_pem, mode: 0600) # Restrict permissions
# Use hardware security modules (HSM) for high-value keys
# Ruby PKCS#11 bindings allow HSM integration:
# hsm_key = PKCS11::Object.new(hsm_session, key_id)
Operating system file permissions restrict unauthorized access. The mode 0600 makes the file readable and writable only by the owner, preventing other users from accessing the encrypted key. However, file permissions alone provide insufficient protection against root access, memory dumps, or system compromise.
Hardware security modules store private keys in tamper-resistant hardware that performs signing operations internally without exposing keys to the host system. HSMs range from USB tokens to enterprise-grade network-attached devices. Applications submit data to the HSM for signing and receive signatures without ever accessing the private key.
Algorithm Selection
Different signature algorithms offer varying security levels, performance characteristics, and compatibility. The choice impacts system security for years:
# Weak: 1024-bit RSA (deprecated, vulnerable to factorization)
weak_key = OpenSSL::PKey::RSA.new(1024)
# Minimum: 2048-bit RSA (acceptable until ~2030)
standard_key = OpenSSL::PKey::RSA.new(2048)
# Strong: 4096-bit RSA (future-proof, slower)
strong_key = OpenSSL::PKey::RSA.new(4096)
# Efficient: P-256 ECDSA (equivalent to 3072-bit RSA)
ec_key = OpenSSL::PKey::EC.generate('prime256v1')
# Stronger: P-384 ECDSA (equivalent to 7680-bit RSA)
strong_ec_key = OpenSSL::PKey::EC.generate('secp384r1')
NIST recommends 2048-bit RSA as the minimum through 2030, with 3072-bit RSA recommended for longer-term security. Keys shorter than 2048 bits should not be used for new systems. ECDSA with P-256 provides comparable security to 3072-bit RSA with significantly better performance and smaller signatures.
Hash Function Security
Digital signatures depend on cryptographic hash functions for integrity. Weak hash functions enable collision attacks where attackers find two different messages producing identical hashes:
# Insecure: MD5 (completely broken)
md5_digest = OpenSSL::Digest.new('MD5')
# Insecure: SHA1 (collision attacks demonstrated)
sha1_digest = OpenSSL::Digest.new('SHA1')
# Minimum: SHA256 (no known attacks)
sha256_digest = OpenSSL::Digest.new('SHA256')
# Stronger: SHA512 (higher security margin)
sha512_digest = OpenSSL::Digest.new('SHA512')
# Modern: SHA3-256 (different construction than SHA2)
sha3_digest = OpenSSL::Digest.new('SHA3-256')
MD5 and SHA-1 must not be used for signatures. Researchers have demonstrated practical collision attacks against both algorithms. An attacker can create two documents with identical MD5 or SHA-1 hashes, allowing them to obtain a signature on a benign document and then substitute a malicious document that validates with the same signature.
Timing Attack Resistance
Signature verification implementations must execute in constant time to prevent timing attacks. Variable-time implementations leak information through execution timing differences:
# Vulnerable: String comparison leaks information
def insecure_verify(signature1, signature2)
signature1 == signature2 # Stops at first mismatch
end
# Secure: Constant-time comparison
def secure_verify(signature1, signature2)
return false unless signature1.bytesize == signature2.bytesize
result = 0
signature1.bytes.zip(signature2.bytes).each do |b1, b2|
result |= b1 ^ b2
end
result == 0
end
Ruby's OpenSSL bindings perform constant-time comparisons internally, but application-level signature handling requires care. The secure implementation examines every byte regardless of mismatches, preventing attackers from learning signature content through timing measurements.
Signature Malleability
Some signature schemes allow attackers to modify signatures in ways that still verify successfully. ECDSA signatures consist of two integers (r, s), and alternative encodings of the same signature can exist:
# ECDSA signatures can have multiple valid encodings
# Always canonicalize signatures to prevent malleability
def canonicalize_ecdsa_signature(signature)
# Parse DER-encoded signature to extract r and s
asn1 = OpenSSL::ASN1.decode(signature)
r = asn1.value[0].value
s = asn1.value[1].value
# Canonicalize s value (low-s form)
# If s > curve_order/2, replace with curve_order - s
# This prevents signature malleability attacks
# Re-encode to DER format
OpenSSL::ASN1::Sequence.new([
OpenSSL::ASN1::Integer.new(r),
OpenSSL::ASN1::Integer.new(s)
]).to_der
end
Bitcoin and other cryptocurrencies require low-s form signatures to prevent transaction malleability attacks. Applications should enforce canonical signature encoding when malleability poses security risks.
Certificate Validation
Public key distribution requires infrastructure to verify key ownership. Digital certificates bind public keys to identities through certificate authority signatures:
# Verify certificate chain
store = OpenSSL::X509::Store.new
store.set_default_paths # Load system CA certificates
cert = OpenSSL::X509::Certificate.new(File.read('server.crt'))
unless store.verify(cert)
raise "Certificate verification failed: #{store.error_string}"
end
# Extract public key from verified certificate
public_key = cert.public_key
# Now safe to verify signatures from this key
Applications must validate the complete certificate chain to trusted root CAs. Skipping certificate validation allows man-in-the-middle attacks where attackers substitute their own keys.
Common Pitfalls
Signing Before Hashing
Developers sometimes sign messages directly without hashing, creating security vulnerabilities and performance problems:
# Wrong: Signing raw message
def vulnerable_sign(message, private_key)
# RSA can only sign data smaller than key size
# This fails for messages > 245 bytes with 2048-bit key
private_key.private_encrypt(message)
end
# Correct: Hash then sign
def secure_sign(message, private_key)
digest = OpenSSL::Digest.new('SHA256')
private_key.sign(digest, message)
end
RSA encryption has size limits based on key size. A 2048-bit key can encrypt at most 245 bytes with OAEP padding. Attempting to sign longer messages directly fails. Hash-then-sign solves this by reducing any message to a fixed-size digest.
Reusing Random Values in ECDSA
ECDSA requires a fresh random value (nonce) for each signature. Reusing nonces leaks the private key:
# This vulnerability compromised PlayStation 3 and various blockchain wallets
# Ruby's OpenSSL generates fresh random values automatically
# Manual implementations must ensure nonce uniqueness
ec_key = OpenSSL::PKey::EC.generate('prime256v1')
# Each signature uses a different random nonce
sig1 = ec_key.sign(OpenSSL::Digest.new('SHA256'), "message1")
sig2 = ec_key.sign(OpenSSL::Digest.new('SHA256'), "message2")
# If nonces were the same, mathematical analysis reveals private key
# OpenSSL prevents this, but custom implementations must be careful
The PlayStation 3 hack exploited ECDSA nonce reuse in the boot loader signature verification. Analysts observed identical r values in different signatures, allowing them to compute the private key and sign arbitrary code. Modern implementations use deterministic nonce generation (RFC 6979) to eliminate this risk.
Accepting Unsigned Data
Applications must explicitly verify signatures before trusting data. Missing verification checks allow attackers to bypass authentication:
# Vulnerable: No signature verification
def process_message(data, signature)
# BUG: Forgot to verify signature before processing
execute_command(data)
end
# Secure: Always verify before use
def process_message(data, signature, public_key)
digest = OpenSSL::Digest.new('SHA256')
unless public_key.verify(digest, signature, data)
raise SecurityError, "Invalid signature"
end
execute_command(data)
end
This category of bugs appears frequently in real systems. The application receives signed data but processes it without verification, defeating the entire purpose of signatures. Code review and testing must ensure verification occurs before trusting signed content.
Wrong Data Being Signed
Signatures must cover all security-relevant data. Partial signing allows attackers to modify unsigned fields:
# Vulnerable: Signing incomplete data
def sign_transaction(from, to, amount, private_key)
# BUG: Forgot to include 'from' field
data = "#{to}:#{amount}"
digest = OpenSSL::Digest.new('SHA256')
private_key.sign(digest, data)
end
# Attacker can change 'from' field without invalidating signature
# Secure: Sign all security-relevant fields
def sign_transaction(from, to, amount, private_key)
data = "#{from}:#{to}:#{amount}"
digest = OpenSSL::Digest.new('SHA256')
private_key.sign(digest, data)
end
Financial systems have suffered from this bug class. An attacker changes the source account while keeping the destination and amount unchanged, and the signature still verifies because those fields weren't signed. Applications must identify all security-critical fields and include them in the signed data.
Signature Without Expiration
Signatures remain valid indefinitely unless the application enforces expiration. Old signatures can be replayed after keys are revoked or policies change:
# Add timestamp and expiration to signatures
def sign_with_expiration(data, private_key, valid_hours = 24)
timestamp = Time.now.to_i
expiration = timestamp + (valid_hours * 3600)
signed_content = {
data: data,
timestamp: timestamp,
expiration: expiration
}
content_json = JSON.generate(signed_content)
digest = OpenSSL::Digest.new('SHA256')
signature = private_key.sign(digest, content_json)
{
content: signed_content,
signature: Base64.strict_encode64(signature)
}
end
def verify_with_expiration(signed_message, public_key)
content = signed_message[:content]
signature = Base64.strict_decode64(signed_message[:signature])
# Check expiration
if Time.now.to_i > content[:expiration]
return { valid: false, reason: 'Signature expired' }
end
# Verify signature
content_json = JSON.generate(content)
digest = OpenSSL::Digest.new('SHA256')
valid = public_key.verify(digest, signature, content_json)
{ valid: valid, data: content[:data] }
end
Including expiration prevents indefinite signature validity. Combined with certificate revocation checking, expiration limits the damage from compromised keys.
Signature Stripping
Applications that make signatures optional allow attackers to remove signatures and bypass verification:
# Vulnerable: Optional signature
def process_api_request(data, signature = nil)
if signature
verify_signature(data, signature)
end
# Executes regardless of signature presence
execute_request(data)
end
# Secure: Require signature
def process_api_request(data, signature)
raise SecurityError, "Missing signature" if signature.nil?
verify_signature(data, signature)
execute_request(data)
end
Making verification conditional on signature presence means attackers can simply omit the signature. All security-critical operations must require signatures without fallback paths.
Testing Approaches
Digital signature testing requires verifying correct behavior, catching implementation errors, and ensuring security properties. Tests must cover cryptographic correctness, error handling, and edge cases.
Basic Signing and Verification Tests
Start with straightforward tests that verify the signing and verification cycle:
require 'minitest/autorun'
require 'openssl'
class DigitalSignatureTest < Minitest::Test
def setup
@private_key = OpenSSL::PKey::RSA.new(2048)
@public_key = @private_key.public_key
@digest = OpenSSL::Digest.new('SHA256')
end
def test_signature_verification_succeeds
message = "Test message"
signature = @private_key.sign(@digest, message)
assert @public_key.verify(@digest, signature, message),
"Valid signature should verify successfully"
end
def test_signature_fails_with_tampered_message
message = "Original message"
signature = @private_key.sign(@digest, message)
tampered = "Tampered message"
refute @public_key.verify(@digest, signature, tampered),
"Signature should fail for modified message"
end
def test_signature_fails_with_wrong_key
message = "Test message"
signature = @private_key.sign(@digest, message)
other_key = OpenSSL::PKey::RSA.new(2048)
refute other_key.public_key.verify(@digest, signature, message),
"Signature should fail with wrong public key"
end
def test_signature_fails_with_corrupted_signature
message = "Test message"
signature = @private_key.sign(@digest, message)
# Flip a bit in the signature
corrupted = signature.dup
corrupted.setbyte(0, corrupted.getbyte(0) ^ 1)
refute @public_key.verify(@digest, corrupted, message),
"Corrupted signature should fail verification"
end
end
These tests establish baseline correctness. Testing tampering detection ensures the implementation rejects invalid signatures rather than accepting them due to implementation bugs.
Algorithm Comparison Tests
Applications supporting multiple signature algorithms need tests comparing their behavior:
class AlgorithmComparisonTest < Minitest::Test
def test_rsa_and_ecdsa_both_verify
message = "Cross-algorithm test"
# RSA signature
rsa_key = OpenSSL::PKey::RSA.new(2048)
rsa_sig = rsa_key.sign(OpenSSL::Digest.new('SHA256'), message)
assert rsa_key.public_key.verify(
OpenSSL::Digest.new('SHA256'), rsa_sig, message
)
# ECDSA signature
ec_key = OpenSSL::PKey::EC.generate('prime256v1')
ec_sig = ec_key.sign(OpenSSL::Digest.new('SHA256'), message)
ec_public = OpenSSL::PKey::EC.new(ec_key.group)
ec_public.public_key = ec_key.public_key
assert ec_public.verify(
OpenSSL::Digest.new('SHA256'), ec_sig, message
)
end
def test_signature_size_differences
message = "Size comparison"
rsa_key = OpenSSL::PKey::RSA.new(2048)
rsa_sig = rsa_key.sign(OpenSSL::Digest.new('SHA256'), message)
ec_key = OpenSSL::PKey::EC.generate('prime256v1')
ec_sig = ec_key.sign(OpenSSL::Digest.new('SHA256'), message)
assert_equal 256, rsa_sig.bytesize, "RSA-2048 signature should be 256 bytes"
assert_operator ec_sig.bytesize, :<, 100, "ECDSA P-256 signature should be < 100 bytes"
end
end
Testing different algorithms ensures the application handles each correctly and validates assumptions about signature sizes and performance characteristics.
Security Property Tests
Critical security properties require explicit testing to catch implementation errors:
class SecurityPropertyTest < Minitest::Test
def test_different_messages_produce_different_signatures
key = OpenSSL::PKey::RSA.new(2048)
digest = OpenSSL::Digest.new('SHA256')
sig1 = key.sign(digest, "message1")
sig2 = key.sign(digest, "message2")
refute_equal sig1, sig2,
"Different messages must produce different signatures"
end
def test_signature_is_deterministic_with_same_input
key = OpenSSL::PKey::RSA.new(2048)
digest = OpenSSL::Digest.new('SHA256')
message = "deterministic test"
sig1 = key.sign(digest, message)
sig2 = key.sign(digest, message)
# Note: Some algorithms (ECDSA) use randomness, making signatures non-deterministic
# This test works for RSA but would fail for ECDSA without RFC 6979
end
def test_signature_includes_entire_message
key = OpenSSL::PKey::RSA.new(2048)
digest = OpenSSL::Digest.new('SHA256')
message = "prefix" + "suffix"
signature = key.sign(digest, message)
# Signature should fail if any part is missing
refute key.public_key.verify(digest, signature, "prefix")
refute key.public_key.verify(digest, signature, "suffix")
assert key.public_key.verify(digest, signature, message)
end
end
These tests verify cryptographic properties that must hold for security. Failures indicate serious implementation problems requiring immediate attention.
Performance and Scale Tests
Production systems need performance testing to identify bottlenecks:
require 'benchmark'
class PerformanceTest < Minitest::Test
def test_signing_performance
key = OpenSSL::PKey::RSA.new(2048)
digest = OpenSSL::Digest.new('SHA256')
message = "Performance test message"
time = Benchmark.measure do
1000.times { key.sign(digest, message) }
end
puts "1000 signatures: #{time.real.round(2)}s"
assert_operator time.real, :<, 10, "Should sign 1000 messages in < 10s"
end
def test_large_file_signing
key = OpenSSL::PKey::RSA.new(2048)
# Create 100MB test file
file_path = 'test_large_file.bin'
File.open(file_path, 'wb') do |f|
f.write('x' * (100 * 1024 * 1024))
end
time = Benchmark.measure do
digest = OpenSSL::Digest.new('SHA256')
File.open(file_path, 'rb') do |file|
while chunk = file.read(8192)
digest.update(chunk)
end
end
key.sign_raw('RSA', digest.digest)
end
puts "100MB file signing: #{time.real.round(2)}s"
File.delete(file_path)
end
end
Performance testing identifies whether signature operations meet latency requirements. Applications signing large volumes of requests or files need performance validation before production deployment.
Reference
Signature Algorithm Comparison
| Algorithm | Key Size | Signature Size | Security Level | Speed | Use Cases |
|---|---|---|---|---|---|
| RSA-2048 | 2048 bits | 256 bytes | ~112 bits | Moderate | Legacy compatibility, wide support |
| RSA-3072 | 3072 bits | 384 bytes | ~128 bits | Slow | Long-term security requirements |
| RSA-4096 | 4096 bits | 512 bytes | ~152 bits | Very slow | Maximum security, infrequent signing |
| ECDSA P-256 | 256 bits | ~72 bytes | ~128 bits | Fast | Modern systems, mobile devices |
| ECDSA P-384 | 384 bits | ~104 bytes | ~192 bits | Fast | High security requirements |
| Ed25519 | 256 bits | 64 bytes | ~128 bits | Very fast | Modern protocols, performance critical |
Ruby OpenSSL Key Classes
| Class | Purpose | Common Methods |
|---|---|---|
| OpenSSL::PKey::RSA | RSA public key cryptography | new, generate, sign, verify, public_key |
| OpenSSL::PKey::EC | Elliptic curve cryptography | generate, sign, verify, public_key |
| OpenSSL::PKey::DSA | DSA signatures (legacy) | new, generate, sign, verify |
| OpenSSL::Digest | Hash functions | new, update, digest, hexdigest |
| OpenSSL::X509::Certificate | X.509 certificates | new, public_key, verify, to_pem |
Hash Algorithm Selection
| Algorithm | Output Size | Status | Recommended Use |
|---|---|---|---|
| MD5 | 128 bits | Broken | Never use |
| SHA-1 | 160 bits | Deprecated | Legacy only, migrate away |
| SHA-256 | 256 bits | Secure | Standard choice |
| SHA-384 | 384 bits | Secure | High security applications |
| SHA-512 | 512 bits | Secure | Maximum security margin |
| SHA3-256 | 256 bits | Secure | Alternative to SHA-2 |
Common Signature Verification Failures
| Error | Cause | Solution |
|---|---|---|
| Signature invalid | Message modified | Verify message integrity, check for tampering |
| Wrong public key | Using incorrect key | Verify key identity through certificates |
| Algorithm mismatch | Different algorithms for sign/verify | Use consistent algorithm throughout |
| Encoding issues | Binary/text encoding problems | Handle signatures as binary data |
| Expired signature | Timestamp past validity period | Check timestamp, update signature |
| Certificate chain failure | Untrusted certificate | Verify certificate chain to trusted root |
Key Storage Best Practices
| Method | Security Level | Use Case |
|---|---|---|
| Plaintext file | None | Never use for private keys |
| Password-encrypted PEM | Low | Development, testing only |
| OS keychain | Medium | Desktop applications |
| HSM | High | Production servers, certificate authorities |
| Cloud KMS | High | Cloud-native applications |
| Smart card | High | User authentication, code signing |
Signature Format Types
| Format | Type | Characteristics | Usage |
|---|---|---|---|
| Raw | Binary | Algorithm-specific format | Direct OpenSSL usage |
| DER | Binary | ASN.1 encoded | Certificates, structured data |
| PEM | Text | Base64-encoded DER | Configuration files, key distribution |
| JWS | JSON | JSON Web Signature | Web APIs, JWT tokens |
| CMS | Binary | Cryptographic Message Syntax | Email, document signing |
| XML DSig | XML | XML digital signatures | SAML, SOAP services |