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 |