CrackedRuby CrackedRuby

Message Authentication Codes

Overview

Message Authentication Codes (MACs) are cryptographic constructs that combine a secret key with message data to produce a fixed-size authentication tag. The recipient, who possesses the same secret key, can verify the tag to confirm both the message's integrity and its authentic origin. Unlike digital signatures, MACs use symmetric cryptography, meaning the same key generates and verifies the tag.

MACs address a fundamental security requirement: confirming that data has not been modified during transmission or storage and that it originated from a party possessing the shared secret key. This differs from simple hash functions, which provide integrity but not authenticity, since anyone can compute a hash. The secret key prevents attackers from generating valid tags for modified messages.

Three primary MAC algorithms dominate modern systems:

HMAC (Hash-based MAC) constructs a MAC using a cryptographic hash function like SHA-256. The algorithm processes the key and message through the hash function twice, providing security properties that depend on the underlying hash function's collision resistance.

require 'openssl'

key = OpenSSL::Random.random_bytes(32)
message = "Transfer $1000 to account 12345"

mac = OpenSSL::HMAC.digest('SHA256', key, message)
# => 32-byte binary authentication tag

CMAC (Cipher-based MAC) builds a MAC using block cipher algorithms like AES. The algorithm processes the message in blocks through the cipher, with the final block receiving special treatment to prevent length extension attacks.

GMAC (Galois MAC) operates as part of Galois/Counter Mode (GCM) authenticated encryption, providing both encryption and authentication. GMAC offers parallelization benefits and hardware acceleration support.

The MAC generation process follows a consistent pattern: combine the secret key and message data through a cryptographic algorithm to produce a fixed-size tag. Verification involves recomputing the tag with the same key and message, then comparing the computed tag with the received tag using a constant-time comparison to prevent timing attacks.

Key Principles

MACs operate on three fundamental properties: unforgeability, determinism, and efficiency. Unforgeability means an attacker without the secret key cannot generate valid tags for chosen messages, even after observing many valid message-tag pairs. Determinism requires that the same key and message always produce identical tags. Efficiency demands that MAC computation and verification complete quickly enough for practical applications.

The security of a MAC depends entirely on the secret key's confidentiality. If an attacker obtains the key, they can forge valid tags for any message. The key must be randomly generated, securely stored, and transmitted only through secure channels. Key size affects security: modern systems use 128-bit or 256-bit keys, matching the security level of the underlying cryptographic primitive.

MAC algorithms transform variable-length messages into fixed-size tags, regardless of input size. HMAC-SHA256 always produces 256-bit (32-byte) tags whether the message contains 10 bytes or 10 megabytes. This fixed output size creates a many-to-one mapping where multiple messages could theoretically produce the same tag. The MAC's security property ensures that finding a different message with the same tag requires infeasible computational resources without the secret key.

Tag truncation is a common practice to reduce bandwidth or storage requirements. HMAC-SHA256 produces 256 bits, but applications frequently use only the first 128 or 96 bits. Truncation weakens security proportionally: a 96-bit tag provides 96-bit security against forgery attempts. The algorithm must explicitly support truncation; arbitrarily truncating tags can create vulnerabilities.

# Full-length tag
full_tag = OpenSSL::HMAC.digest('SHA256', key, message)
# => 32 bytes

# Truncated tag
truncated_tag = OpenSSL::HMAC.digest('SHA256', key, message)[0...16]
# => 16 bytes (128-bit security)

The MAC verification process requires constant-time comparison of the computed and received tags. Variable-time comparison creates timing side channels where attackers measure verification time to determine which tag bytes are correct. A secure comparison checks all bytes regardless of when a mismatch occurs.

def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize
  
  result = 0
  a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
  result == 0
end

HMAC's nested construction provides security even when the underlying hash function has certain weaknesses. The algorithm applies the hash function twice: first to the key and message, then to the key and first hash result. This double application prevents length extension attacks that affect simple hash-based constructions. HMAC-MD5 remains secure for authentication despite MD5's collision vulnerabilities, though SHA-256 is recommended for new systems.

The MAC lifecycle includes key generation, tag generation, and tag verification phases. Key generation must use cryptographically secure random number generators. Tag generation occurs once per message. Verification happens at least once per message but may occur multiple times if multiple parties need to verify authenticity.

Key rotation involves generating new keys periodically and re-authenticating data with new tags. Systems rotate keys to limit the damage from potential key compromise and to satisfy compliance requirements. The rotation schedule depends on factors like message volume, key storage security, and regulatory mandates.

Ruby Implementation

Ruby provides MAC functionality primarily through the OpenSSL library, which exposes HMAC and CMAC implementations. The OpenSSL::HMAC class offers the most commonly used interface for hash-based MACs, while OpenSSL::CMAC handles cipher-based MACs.

The OpenSSL::HMAC class supports both one-shot digest computation and incremental processing for large messages. The one-shot interface accepts a hash algorithm name, key, and complete message data:

require 'openssl'

# Generate a secure random key
key = OpenSSL::Random.random_bytes(32)

# One-shot HMAC computation
message = "Authenticate this message"
tag = OpenSSL::HMAC.digest('SHA256', key, message)

puts tag.unpack1('H*')
# => hexadecimal representation of 32-byte tag

Incremental processing handles messages too large for memory or arriving in chunks over a network connection:

hmac = OpenSSL::HMAC.new(key, 'SHA256')

# Process message in chunks
File.open('large_file.dat', 'rb') do |file|
  while chunk = file.read(8192)
    hmac << chunk
  end
end

tag = hmac.digest

The hexdigest method returns tags as hexadecimal strings instead of binary data, which is useful for storage in text-based formats or transmission over protocols that handle text better than binary:

hex_tag = OpenSSL::HMAC.hexdigest('SHA256', key, message)
# => "a3f5b1c2..." (64 hexadecimal characters for SHA256)

Ruby's OpenSSL bindings support multiple hash algorithms for HMAC construction. Common choices include SHA-256, SHA-384, and SHA-512 from the SHA-2 family, plus SHA3-256 and SHA3-512 from the SHA-3 family:

# Different hash functions produce different tag sizes
sha256_tag = OpenSSL::HMAC.digest('SHA256', key, message)
# => 32 bytes

sha512_tag = OpenSSL::HMAC.digest('SHA512', key, message)
# => 64 bytes

sha3_256_tag = OpenSSL::HMAC.digest('SHA3-256', key, message)
# => 32 bytes

CMAC implementation uses the OpenSSL::CMAC class with block cipher algorithms. AES is the standard cipher choice:

# CMAC requires OpenSSL 1.0.1 or later
cmac = OpenSSL::CMAC.new(key, 'AES-128-CBC')
cmac.update(message)
tag = cmac.digest

Creating a reusable MAC wrapper class encapsulates key management and provides a cleaner interface:

class MessageAuthenticator
  def initialize(key, algorithm: 'SHA256')
    @key = key
    @algorithm = algorithm
  end
  
  def generate_tag(message)
    OpenSSL::HMAC.digest(@algorithm, @key, message)
  end
  
  def verify_tag(message, tag)
    computed = generate_tag(message)
    secure_compare(computed, tag)
  end
  
  private
  
  def secure_compare(a, b)
    return false unless a.bytesize == b.bytesize
    
    result = 0
    a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
    result == 0
  end
end

# Usage
auth = MessageAuthenticator.new(key)
tag = auth.generate_tag("Important data")
valid = auth.verify_tag("Important data", tag)
# => true

Base64 encoding provides a portable format for storing or transmitting binary tags:

require 'base64'

tag = OpenSSL::HMAC.digest('SHA256', key, message)
encoded_tag = Base64.strict_encode64(tag)
# => "o3Vbwc8P..." (44 characters for 32-byte tag)

# Verification with encoded tag
received_tag = Base64.strict_decode64(encoded_tag)
valid = OpenSSL::HMAC.digest('SHA256', key, message) == received_tag

Key derivation from passwords uses PBKDF2 or similar functions to generate MAC keys from human-memorable passwords:

def derive_mac_key(password, salt, iterations: 100_000)
  OpenSSL::PKCS5.pbkdf2_hmac(
    password,
    salt,
    iterations,
    32,  # output length in bytes
    'SHA256'
  )
end

salt = OpenSSL::Random.random_bytes(16)
mac_key = derive_mac_key("user_password", salt)

Rails applications integrate MACs through ActiveSupport's message verification:

# ActiveSupport uses HMAC internally
verifier = ActiveSupport::MessageVerifier.new(Rails.application.secret_key_base)

# Sign data
signed_data = verifier.generate(user_id: 123, expires_at: 1.hour.from_now)

# Verify and extract data
data = verifier.verify(signed_data)
# => { user_id: 123, expires_at: ... }
# Raises ActiveSupport::MessageVerifier::InvalidSignature if tampered

Practical Examples

Protecting API request integrity requires authenticating both the request body and critical headers. The MAC prevents attackers from modifying requests in transit:

class ApiRequestSigner
  def initialize(api_key, api_secret)
    @api_key = api_key
    @api_secret = api_secret
  end
  
  def sign_request(method, path, body, timestamp)
    # Construct canonical string including all critical elements
    canonical_string = [
      method.upcase,
      path,
      timestamp.to_s,
      body
    ].join("\n")
    
    signature = OpenSSL::HMAC.hexdigest('SHA256', @api_secret, canonical_string)
    
    {
      'X-API-Key' => @api_key,
      'X-Timestamp' => timestamp.to_s,
      'X-Signature' => signature
    }
  end
  
  def verify_request(method, path, body, timestamp, received_signature)
    # Reject old requests to prevent replay attacks
    return false if Time.now.to_i - timestamp.to_i > 300
    
    canonical_string = [
      method.upcase,
      path,
      timestamp.to_s,
      body
    ].join("\n")
    
    expected = OpenSSL::HMAC.hexdigest('SHA256', @api_secret, canonical_string)
    secure_compare(expected, received_signature)
  end
  
  private
  
  def secure_compare(a, b)
    return false unless a.bytesize == b.bytesize
    a.bytes.zip(b.bytes).all? { |x, y| x == y }
  end
end

# Client usage
signer = ApiRequestSigner.new('client_key_123', 'secret_abc')
timestamp = Time.now.to_i
headers = signer.sign_request('POST', '/api/users', '{"name":"Alice"}', timestamp)

# Server verification
verified = signer.verify_request(
  'POST',
  '/api/users',
  '{"name":"Alice"}',
  timestamp,
  headers['X-Signature']
)

Secure cookie management authenticates session data to prevent client-side tampering. Web frameworks use this pattern to store session information in cookies while maintaining security:

class SecureCookie
  def initialize(secret_key)
    @secret_key = secret_key
  end
  
  def encode(data)
    json = JSON.generate(data)
    encoded_data = Base64.urlsafe_encode64(json)
    mac = OpenSSL::HMAC.hexdigest('SHA256', @secret_key, encoded_data)
    
    "#{encoded_data}.#{mac}"
  end
  
  def decode(cookie_value)
    encoded_data, received_mac = cookie_value.split('.', 2)
    return nil unless received_mac
    
    expected_mac = OpenSSL::HMAC.hexdigest('SHA256', @secret_key, encoded_data)
    return nil unless secure_compare(expected_mac, received_mac)
    
    json = Base64.urlsafe_decode64(encoded_data)
    JSON.parse(json)
  rescue StandardError
    nil
  end
  
  private
  
  def secure_compare(a, b)
    return false unless a.length == b.length
    a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) } == 0
  end
end

# Usage in web application
cookie_manager = SecureCookie.new(ENV['COOKIE_SECRET'])

# Set cookie
session_data = { user_id: 42, role: 'admin' }
cookie_value = cookie_manager.encode(session_data)
response.set_cookie('session', value: cookie_value, httponly: true)

# Read cookie
if session_data = cookie_manager.decode(request.cookies['session'])
  current_user = User.find(session_data['user_id'])
end

File integrity verification uses MACs to detect unauthorized modifications. This pattern appears in software distribution, backup verification, and secure logging:

class FileIntegrityChecker
  def initialize(key_file)
    @key = File.binread(key_file)
  end
  
  def generate_manifest(directory)
    manifest = {}
    
    Dir.glob("#{directory}/**/*").each do |path|
      next unless File.file?(path)
      
      file_mac = compute_file_mac(path)
      relative_path = path.sub("#{directory}/", '')
      manifest[relative_path] = {
        mac: Base64.strict_encode64(file_mac),
        size: File.size(path),
        modified: File.mtime(path).to_i
      }
    end
    
    File.write("#{directory}/MANIFEST.json", JSON.pretty_generate(manifest))
  end
  
  def verify_manifest(directory)
    manifest = JSON.parse(File.read("#{directory}/MANIFEST.json"))
    results = { valid: [], invalid: [], missing: [] }
    
    manifest.each do |relative_path, info|
      full_path = File.join(directory, relative_path)
      
      unless File.exist?(full_path)
        results[:missing] << relative_path
        next
      end
      
      stored_mac = Base64.strict_decode64(info['mac'])
      computed_mac = compute_file_mac(full_path)
      
      if secure_compare(stored_mac, computed_mac)
        results[:valid] << relative_path
      else
        results[:invalid] << relative_path
      end
    end
    
    results
  end
  
  private
  
  def compute_file_mac(path)
    hmac = OpenSSL::HMAC.new(@key, 'SHA256')
    
    File.open(path, 'rb') do |file|
      while chunk = file.read(65536)
        hmac << chunk
      end
    end
    
    hmac.digest
  end
  
  def secure_compare(a, b)
    return false unless a.bytesize == b.bytesize
    a.bytes.zip(b.bytes).all? { |x, y| x == y }
  end
end

# Usage
checker = FileIntegrityChecker.new('/secure/mac.key')
checker.generate_manifest('/app/releases/v1.2.3')

# Later verification
results = checker.verify_manifest('/app/releases/v1.2.3')
puts "Valid: #{results[:valid].count}"
puts "Invalid: #{results[:invalid].count}"
puts "Missing: #{results[:missing].count}"

Webhook payload authentication ensures received webhooks originate from legitimate sources. Services like GitHub and Stripe use HMAC signatures to authenticate webhooks:

class WebhookValidator
  def initialize(webhook_secret)
    @secret = webhook_secret
  end
  
  def validate_github_webhook(payload, signature_header)
    # GitHub sends: X-Hub-Signature-256: sha256=<hex_signature>
    return false unless signature_header&.start_with?('sha256=')
    
    received_signature = signature_header.sub('sha256=', '')
    computed_signature = OpenSSL::HMAC.hexdigest('SHA256', @secret, payload)
    
    secure_compare(received_signature, computed_signature)
  end
  
  def validate_stripe_webhook(payload, signature_header, timestamp_tolerance: 300)
    # Stripe sends: t=<timestamp>,v1=<signature>,v1=<signature>
    timestamp, *signatures = parse_stripe_signature(signature_header)
    return false unless timestamp && signatures.any?
    
    # Prevent replay attacks
    return false if Time.now.to_i - timestamp.to_i > timestamp_tolerance
    
    signed_payload = "#{timestamp}.#{payload}"
    expected = OpenSSL::HMAC.hexdigest('SHA256', @secret, signed_payload)
    
    signatures.any? { |sig| secure_compare(sig, expected) }
  end
  
  private
  
  def parse_stripe_signature(header)
    parts = header.split(',')
    timestamp = nil
    signatures = []
    
    parts.each do |part|
      key, value = part.split('=', 2)
      timestamp = value.to_i if key == 't'
      signatures << value if key == 'v1'
    end
    
    [timestamp, *signatures]
  end
  
  def secure_compare(a, b)
    return false unless a.length == b.length
    a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) } == 0
  end
end

# Sinatra webhook endpoint
post '/webhooks/github' do
  payload = request.body.read
  signature = request.env['HTTP_X_HUB_SIGNATURE_256']
  
  validator = WebhookValidator.new(ENV['GITHUB_WEBHOOK_SECRET'])
  
  unless validator.validate_github_webhook(payload, signature)
    halt 401, 'Invalid signature'
  end
  
  event = JSON.parse(payload)
  process_github_event(event)
  
  status 200
end

Security Implications

MAC security relies fundamentally on secret key confidentiality. If an attacker obtains the key, all security guarantees vanish. The attacker can generate valid tags for any message, forge authenticated data, and potentially decrypt messages if the same key protects both authenticity and confidentiality. Key storage must use hardware security modules, encrypted key stores, or secure key management services for production systems.

Tag comparison timing attacks exploit microsecond differences in comparison operations to leak information about the correct tag value. A naive comparison that returns immediately upon finding a mismatch allows attackers to guess tag bytes sequentially by measuring response times:

# VULNERABLE: timing attack susceptible
def insecure_verify(computed, received)
  return false unless computed.length == received.length
  
  computed.bytes.zip(received.bytes).each do |c, r|
    return false if c != r  # Returns immediately on mismatch
  end
  
  true
end

# SECURE: constant-time comparison
def secure_verify(computed, received)
  return false unless computed.bytesize == received.bytesize
  
  diff = 0
  computed.bytes.zip(received.bytes) { |c, r| diff |= c ^ r }
  diff == 0
end

Replay attacks reuse valid message-tag pairs captured from legitimate communications. An attacker intercepts an authenticated "transfer $100" request and resends it multiple times. Defenses include timestamps, nonces, or sequence numbers in the authenticated data:

def generate_timestamped_tag(message, key)
  timestamp = Time.now.to_i
  data = "#{timestamp}:#{message}"
  tag = OpenSSL::HMAC.digest('SHA256', key, data)
  
  { message: message, timestamp: timestamp, tag: Base64.strict_encode64(tag) }
end

def verify_timestamped_tag(data, key, max_age: 300)
  timestamp = data[:timestamp]
  return false if Time.now.to_i - timestamp > max_age
  
  canonical = "#{timestamp}:#{data[:message]}"
  received_tag = Base64.strict_decode64(data[:tag])
  computed_tag = OpenSSL::HMAC.digest('SHA256', key, canonical)
  
  secure_compare(received_tag, computed_tag)
end

Key reuse across different purposes creates cross-protocol attacks. Using the same key for request signing and cookie authentication allows an attacker to forge cookies using captured request signatures. Each security purpose requires a distinct key or key derivation:

def derive_purpose_key(master_key, purpose)
  OpenSSL::HMAC.digest('SHA256', master_key, purpose)
end

# Separate keys for different purposes
cookie_key = derive_purpose_key(master_key, "cookie-signing-v1")
api_key = derive_purpose_key(master_key, "api-request-signing-v1")

Short MAC tags reduce security proportionally. A 64-bit tag provides only 64-bit security against forgery: attackers need 2^64 attempts on average to forge a valid tag. Modern systems use 128-bit or 256-bit tags. Applications that require shorter tags must accept reduced security or implement rate limiting and attempt monitoring.

Message ordering attacks exploit systems that authenticate individual messages but not message sequences. An attacker reorders authenticated messages to change system behavior. Solutions include sequence numbers in authenticated data or authenticating message batches:

class SequencedAuthenticator
  def initialize(key)
    @key = key
    @sequence = 0
  end
  
  def authenticate_message(message)
    @sequence += 1
    data = "#{@sequence}:#{message}"
    tag = OpenSSL::HMAC.digest('SHA256', @key, data)
    
    { sequence: @sequence, message: message, tag: tag }
  end
  
  def verify_message(data, expected_sequence)
    return false unless data[:sequence] == expected_sequence
    
    canonical = "#{data[:sequence]}:#{data[:message]}"
    computed = OpenSSL::HMAC.digest('SHA256', @key, canonical)
    
    secure_compare(data[:tag], computed)
  end
  
  private
  
  def secure_compare(a, b)
    return false unless a.bytesize == b.bytesize
    a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) } == 0
  end
end

Length extension attacks affect naive MAC constructions based on hash functions without proper domain separation. HMAC's double-hashing construction prevents these attacks, but custom MAC implementations using raw hash functions remain vulnerable. Always use established MAC algorithms rather than constructing custom authentication schemes.

Side-channel attacks exploit physical properties of computation. Power analysis and electromagnetic emanation can reveal key bits during MAC computation on embedded devices. Constant-time implementations and algorithmic countermeasures mitigate these attacks. Ruby applications running on cloud infrastructure face fewer side-channel risks than embedded firmware.

Key compromise detection requires careful logging and monitoring. Systems should detect unusual authentication failures, authentication from unexpected locations, or authentication outside normal time windows. Key rotation limits damage from undetected compromises.

Performance Considerations

HMAC performance depends primarily on the underlying hash function and message size. SHA-256 processes data at several hundred megabytes per second on modern CPUs. SHA-512 often runs faster than SHA-256 on 64-bit processors despite producing larger output. SHA-3 generally performs slower than SHA-2 for the same output size.

require 'benchmark'

key = OpenSSL::Random.random_bytes(32)
message = "x" * 1_000_000  # 1MB message

Benchmark.bmbm do |x|
  x.report("HMAC-SHA256") do
    1000.times { OpenSSL::HMAC.digest('SHA256', key, message) }
  end
  
  x.report("HMAC-SHA512") do
    1000.times { OpenSSL::HMAC.digest('SHA512', key, message) }
  end
  
  x.report("HMAC-SHA3-256") do
    1000.times { OpenSSL::HMAC.digest('SHA3-256', key, message) }
  end
end

Incremental processing significantly improves memory efficiency for large messages. Loading a 1GB file into memory for MAC computation requires 1GB of RAM and delays computation until the entire file loads. Incremental processing uses fixed memory regardless of file size:

def mac_large_file_memory_hungry(path, key)
  message = File.binread(path)  # Loads entire file
  OpenSSL::HMAC.digest('SHA256', key, message)
end

def mac_large_file_streaming(path, key)
  hmac = OpenSSL::HMAC.new(key, 'SHA256')
  
  File.open(path, 'rb') do |file|
    while chunk = file.read(65536)
      hmac << chunk
    end
  end
  
  hmac.digest
end

Tag size affects network transmission and storage costs but has minimal impact on computation time. Truncating a 256-bit HMAC-SHA256 tag to 128 bits saves 16 bytes per message. At one million messages per day, this saves 16MB daily or 5.8GB annually. The security reduction must be acceptable for the application's threat model.

Caching MAC values reduces redundant computation when the same data is authenticated repeatedly. Cache keys must include both the message and the MAC key identifier to prevent using cached values with different keys:

class CachedMacGenerator
  def initialize(key)
    @key = key
    @key_id = OpenSSL::Digest::SHA256.hexdigest(key)[0..7]
    @cache = {}
  end
  
  def generate_tag(message)
    cache_key = "#{@key_id}:#{OpenSSL::Digest::SHA256.hexdigest(message)}"
    
    @cache[cache_key] ||= OpenSSL::HMAC.digest('SHA256', @key, message)
  end
  
  def clear_cache
    @cache.clear
  end
end

Batch authentication amortizes overhead across multiple messages. Systems processing many small messages benefit from authenticating batches rather than individual messages:

def authenticate_messages_individually(messages, key)
  messages.map do |msg|
    { message: msg, tag: OpenSSL::HMAC.digest('SHA256', key, msg) }
  end
end

def authenticate_messages_batch(messages, key)
  combined = messages.join("\x00")
  batch_tag = OpenSSL::HMAC.digest('SHA256', key, combined)
  
  { messages: messages, tag: batch_tag }
end

Parallel MAC computation exploits multiple cores when processing independent messages. Ruby's Global Interpreter Lock limits parallelism in pure Ruby, but native extensions release the GIL during cryptographic operations:

require 'concurrent'

def parallel_mac_generation(messages, key)
  pool = Concurrent::FixedThreadPool.new(4)
  futures = messages.map do |msg|
    Concurrent::Future.execute(executor: pool) do
      OpenSSL::HMAC.digest('SHA256', key, msg)
    end
  end
  
  futures.map(&:value)
ensure
  pool.shutdown
end

Hardware acceleration through AES-NI instructions significantly improves CMAC performance on processors supporting these instructions. OpenSSL automatically uses hardware acceleration when available. HMAC does not benefit from AES-NI but may benefit from SHA extensions on supported processors.

Common Pitfalls

Using the same key for encryption and authentication creates opportunities for cryptographic attacks. Encrypt-then-MAC requires two independent keys: one for encryption and one for MAC computation. Key reuse allows attackers to exploit relationships between the cipher and MAC:

# WRONG: single key for both operations
def encrypt_and_mac_wrong(plaintext, key, iv)
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
  cipher.encrypt
  cipher.key = key
  cipher.iv = iv
  
  ciphertext = cipher.update(plaintext) + cipher.final
  tag = OpenSSL::HMAC.digest('SHA256', key, ciphertext)  # Same key!
  
  { ciphertext: ciphertext, tag: tag, iv: iv }
end

# CORRECT: separate keys
def encrypt_and_mac_correct(plaintext, encryption_key, mac_key, iv)
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
  cipher.encrypt
  cipher.key = encryption_key
  cipher.iv = iv
  
  ciphertext = cipher.update(plaintext) + cipher.final
  tag = OpenSSL::HMAC.digest('SHA256', mac_key, ciphertext)
  
  { ciphertext: ciphertext, tag: tag, iv: iv }
end

Verifying the MAC after decryption exposes systems to padding oracle attacks and timing attacks. The correct order is verify-then-decrypt: verify the MAC first, then decrypt only if verification succeeds. Decrypting invalid ciphertexts wastes computation and may leak information.

# WRONG: decrypt then verify
def decrypt_wrong(data, encryption_key, mac_key)
  # Decrypt first
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
  cipher.decrypt
  cipher.key = encryption_key
  cipher.iv = data[:iv]
  plaintext = cipher.update(data[:ciphertext]) + cipher.final
  
  # Verify MAC second
  computed_tag = OpenSSL::HMAC.digest('SHA256', mac_key, data[:ciphertext])
  raise "Invalid MAC" unless secure_compare(computed_tag, data[:tag])
  
  plaintext
end

# CORRECT: verify then decrypt
def decrypt_correct(data, encryption_key, mac_key)
  # Verify MAC first
  computed_tag = OpenSSL::HMAC.digest('SHA256', mac_key, data[:ciphertext])
  raise "Invalid MAC" unless secure_compare(computed_tag, data[:tag])
  
  # Decrypt second
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
  cipher.decrypt
  cipher.key = encryption_key
  cipher.iv = data[:iv]
  cipher.update(data[:ciphertext]) + cipher.final
end

Authenticating only part of the message allows attackers to modify unauthenticated portions. The MAC must cover all security-relevant data including headers, metadata, configuration flags, and message content. Omitting the initialization vector from authenticated data enables IV manipulation attacks.

# WRONG: IV not authenticated
def encrypt_wrong(plaintext, key, mac_key)
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
  cipher.encrypt
  cipher.key = key
  iv = cipher.random_iv
  ciphertext = cipher.update(plaintext) + cipher.final
  
  tag = OpenSSL::HMAC.digest('SHA256', mac_key, ciphertext)  # IV missing!
  { ciphertext: ciphertext, iv: iv, tag: tag }
end

# CORRECT: IV included in MAC
def encrypt_correct(plaintext, key, mac_key)
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
  cipher.encrypt
  cipher.key = key
  iv = cipher.random_iv
  ciphertext = cipher.update(plaintext) + cipher.final
  
  authenticated_data = iv + ciphertext
  tag = OpenSSL::HMAC.digest('SHA256', mac_key, authenticated_data)
  
  { ciphertext: ciphertext, iv: iv, tag: tag }
end

Insufficient key entropy weakens MAC security regardless of algorithm strength. Keys derived from passwords require key derivation functions like PBKDF2, bcrypt, or Argon2. Direct hashing of passwords produces keys vulnerable to dictionary attacks.

# WRONG: weak key derivation
weak_key = OpenSSL::Digest::SHA256.digest("password123")

# CORRECT: proper key derivation
def derive_key_from_password(password, salt)
  OpenSSL::PKCS5.pbkdf2_hmac(
    password,
    salt,
    100_000,  # iterations
    32,       # key length
    'SHA256'
  )
end

salt = OpenSSL::Random.random_bytes(16)
strong_key = derive_key_from_password("password123", salt)

Missing rate limiting on verification failures enables online forgery attacks. Attackers can try many forged tags until one succeeds. Systems must limit authentication attempts per time period and exponentially increase delays after failures.

Storing MAC keys in application code or configuration files creates security vulnerabilities. Keys must reside in secure key management systems, environment variables, or encrypted configuration stores with restricted access.

Converting between string encodings incorrectly causes verification failures. Binary MAC tags must use consistent encoding throughout their lifecycle. Base64 encoding provides safe text representation:

# WRONG: string encoding issues
tag = OpenSSL::HMAC.digest('SHA256', key, message)
stored_tag = tag.to_s  # Incorrect conversion

# CORRECT: proper encoding
tag = OpenSSL::HMAC.digest('SHA256', key, message)
stored_tag = Base64.strict_encode64(tag)
retrieved_tag = Base64.strict_decode64(stored_tag)

Testing Approaches

MAC testing must verify both correct authentication of valid messages and rejection of invalid or tampered messages. Test suites should cover happy paths, forgery attempts, tampering detection, and edge cases.

require 'minitest/autorun'

class MacAuthenticationTest < Minitest::Test
  def setup
    @key = OpenSSL::Random.random_bytes(32)
    @message = "Test message for authentication"
  end
  
  def test_generates_consistent_tags
    tag1 = OpenSSL::HMAC.digest('SHA256', @key, @message)
    tag2 = OpenSSL::HMAC.digest('SHA256', @key, @message)
    
    assert_equal tag1, tag2
  end
  
  def test_different_messages_produce_different_tags
    message1 = "First message"
    message2 = "Second message"
    
    tag1 = OpenSSL::HMAC.digest('SHA256', @key, message1)
    tag2 = OpenSSL::HMAC.digest('SHA256', @key, message2)
    
    refute_equal tag1, tag2
  end
  
  def test_different_keys_produce_different_tags
    key1 = OpenSSL::Random.random_bytes(32)
    key2 = OpenSSL::Random.random_bytes(32)
    
    tag1 = OpenSSL::HMAC.digest('SHA256', key1, @message)
    tag2 = OpenSSL::HMAC.digest('SHA256', key2, @message)
    
    refute_equal tag1, tag2
  end
  
  def test_detects_message_tampering
    tag = OpenSSL::HMAC.digest('SHA256', @key, @message)
    tampered_message = @message + " tampered"
    
    computed_tag = OpenSSL::HMAC.digest('SHA256', @key, tampered_message)
    refute_equal tag, computed_tag
  end
  
  def test_detects_tag_tampering
    tag = OpenSSL::HMAC.digest('SHA256', @key, @message)
    tampered_tag = tag.dup
    tampered_tag[0] = (tampered_tag[0].ord ^ 1).chr
    
    refute_equal tag, tampered_tag
  end
  
  def test_verification_with_correct_tag
    tag = OpenSSL::HMAC.digest('SHA256', @key, @message)
    computed_tag = OpenSSL::HMAC.digest('SHA256', @key, @message)
    
    assert secure_compare(tag, computed_tag)
  end
  
  def test_verification_with_incorrect_tag
    tag = OpenSSL::HMAC.digest('SHA256', @key, @message)
    wrong_key = OpenSSL::Random.random_bytes(32)
    wrong_tag = OpenSSL::HMAC.digest('SHA256', wrong_key, @message)
    
    refute secure_compare(tag, wrong_tag)
  end
  
  private
  
  def secure_compare(a, b)
    return false unless a.bytesize == b.bytesize
    a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) } == 0
  end
end

Testing against known test vectors validates implementation correctness. HMAC RFC 4231 provides standard test vectors:

class HmacTestVectors < Minitest::Test
  def test_rfc4231_test_case_1
    key = ["0b" * 20].pack("H*")
    data = "Hi There"
    expected = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
    
    result = OpenSSL::HMAC.hexdigest('SHA256', key, data)
    assert_equal expected, result
  end
  
  def test_rfc4231_test_case_2
    key = "Jefe"
    data = "what do ya want for nothing?"
    expected = "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
    
    result = OpenSSL::HMAC.hexdigest('SHA256', key, data)
    assert_equal expected, result
  end
  
  def test_rfc4231_test_case_6
    key = ["aa" * 131].pack("H*")
    data = "Test Using Larger Than Block-Size Key - Hash Key First"
    expected = "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
    
    result = OpenSSL::HMAC.hexdigest('SHA256', key, data)
    assert_equal expected, result
  end
end

Property-based testing verifies MAC properties across many random inputs:

class MacPropertyTest < Minitest::Test
  def test_tag_length_consistent
    key = OpenSSL::Random.random_bytes(32)
    
    100.times do
      message = OpenSSL::Random.random_bytes(rand(1..1000))
      tag = OpenSSL::HMAC.digest('SHA256', key, message)
      
      assert_equal 32, tag.bytesize
    end
  end
  
  def test_deterministic_output
    key = OpenSSL::Random.random_bytes(32)
    message = OpenSSL::Random.random_bytes(100)
    
    tags = 10.times.map { OpenSSL::HMAC.digest('SHA256', key, message) }
    
    assert tags.all? { |tag| tag == tags.first }
  end
  
  def test_key_sensitivity
    message = "Test message"
    
    100.times do
      key1 = OpenSSL::Random.random_bytes(32)
      key2 = OpenSSL::Random.random_bytes(32)
      
      tag1 = OpenSSL::HMAC.digest('SHA256', key1, message)
      tag2 = OpenSSL::HMAC.digest('SHA256', key2, message)
      
      refute_equal tag1, tag2
    end
  end
end

Integration testing verifies MAC usage in complete authentication workflows:

class ApiAuthenticationIntegrationTest < Minitest::Test
  def setup
    @api_key = 'test_key_123'
    @api_secret = OpenSSL::Random.random_bytes(32)
    @signer = ApiRequestSigner.new(@api_key, @api_secret)
  end
  
  def test_complete_request_signing_flow
    method = 'POST'
    path = '/api/users'
    body = '{"name":"Alice","email":"alice@example.com"}'
    timestamp = Time.now.to_i
    
    headers = @signer.sign_request(method, path, body, timestamp)
    
    assert_equal @api_key, headers['X-API-Key']
    assert_equal timestamp.to_s, headers['X-Timestamp']
    assert headers['X-Signature']
    
    verified = @signer.verify_request(
      method,
      path,
      body,
      timestamp,
      headers['X-Signature']
    )
    
    assert verified
  end
  
  def test_rejects_modified_request
    method = 'POST'
    path = '/api/users'
    body = '{"name":"Alice"}'
    timestamp = Time.now.to_i
    
    headers = @signer.sign_request(method, path, body, timestamp)
    
    # Attacker modifies body
    modified_body = '{"name":"Mallory"}'
    
    verified = @signer.verify_request(
      method,
      path,
      modified_body,
      timestamp,
      headers['X-Signature']
    )
    
    refute verified
  end
  
  def test_rejects_replay_attacks
    method = 'GET'
    path = '/api/balance'
    body = ''
    old_timestamp = Time.now.to_i - 400  # 400 seconds ago
    
    headers = @signer.sign_request(method, path, body, old_timestamp)
    
    verified = @signer.verify_request(
      method,
      path,
      body,
      old_timestamp,
      headers['X-Signature']
    )
    
    refute verified  # Too old
  end
end

Reference

Common HMAC Hash Functions

Hash Function Output Size Security Level Common Use Cases
HMAC-SHA256 32 bytes 256 bits General purpose authentication
HMAC-SHA512 64 bytes 512 bits High security requirements
HMAC-SHA384 48 bytes 384 bits Balance between SHA256 and SHA512
HMAC-SHA1 20 bytes deprecated Legacy system compatibility only
HMAC-SHA3-256 32 bytes 256 bits Alternative to SHA-2
HMAC-MD5 16 bytes deprecated Legacy systems only

Ruby OpenSSL::HMAC Methods

Method Description Returns
OpenSSL::HMAC.digest(algorithm, key, data) Compute HMAC in one shot Binary string
OpenSSL::HMAC.hexdigest(algorithm, key, data) Compute HMAC as hex string Hex string
OpenSSL::HMAC.new(key, algorithm) Create incremental HMAC HMAC instance
hmac.update(data) Add data to incremental HMAC self
hmac.digest Finalize and return binary tag Binary string
hmac.hexdigest Finalize and return hex tag Hex string
hmac.reset Reset HMAC state self

Security Properties

Property Description Implication
Unforgeability Cannot create valid tags without key Prevents message forgery
Deterministic Same input produces same output Enables verification
Key sensitivity Key change changes output completely Prevents key guessing
Avalanche effect Small input change changes output significantly Detects tampering
Collision resistance Hard to find two inputs with same tag Prevents substitution attacks
Pre-image resistance Cannot derive input from tag Protects message content

Recommended Key Sizes

Algorithm Minimum Key Size Recommended Key Size Maximum Useful Size
HMAC-SHA256 32 bytes 32 bytes 64 bytes
HMAC-SHA512 64 bytes 64 bytes 128 bytes
HMAC-SHA3-256 32 bytes 32 bytes 64 bytes
CMAC-AES 16 bytes 32 bytes 32 bytes

Tag Truncation Guidelines

Full Tag Size Minimum Truncated Size Security Level Use Case
256 bits 128 bits High Standard applications
256 bits 96 bits Medium Bandwidth-constrained
256 bits 80 bits Low Legacy compatibility
512 bits 256 bits Very high High security requirements

Common Implementation Patterns

Pattern Code Example Purpose
Basic authentication OpenSSL::HMAC.digest('SHA256', key, message) Simple message authentication
Timestamped MAC HMAC.digest(algo, key, timestamp + message) Prevent replay attacks
Keyed hash HMAC.hexdigest('SHA256', key, data) Human-readable tags
Incremental MAC hmac.update(chunk); hmac.digest Large file processing
Truncated tag HMAC.digest(algo, key, msg)[0...16] Reduce bandwidth

Error Handling Checklist

Scenario Check Action
Invalid key Key nil or wrong size Raise argument error
Invalid algorithm Unknown hash function Raise argument error
Verification failure Tags do not match Reject message
Timestamp expired Time difference too large Reject message
Missing tag Tag not provided Reject message
Tag length mismatch Received tag wrong size Reject message

Comparison Operations

Operation Safe Reason
tag1 == tag2 No Timing attack vulnerable
tag1.eql?(tag2) No Timing attack vulnerable
constant_time_compare(tag1, tag2) Yes Constant time operation
ActiveSupport::SecurityUtils.secure_compare Yes Constant time operation

Key Derivation Parameters

Parameter Recommended Value Purpose
PBKDF2 iterations 100,000+ Slow down brute force
Salt size 16 bytes minimum Prevent rainbow tables
Derived key length 32 bytes Match HMAC-SHA256
Hash function SHA256 or SHA512 Security strength