CrackedRuby CrackedRuby

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