Skip to content

[13.x] Multi-key decryption loop leaks key position via timing side-channel #59363

Description

@abdallahk

Description

The decrypt() method in Encrypter.php iterates over all encryption keys and short-circuits MAC validation once a valid key is found:

File: src/Illuminate/Encryption/Encrypter.php lines 170-185

$foundValidMac = false;

foreach ($this->getAllKeys() as $key) {
    if (
        $this->shouldValidateMac() &&
        ! ($foundValidMac = $foundValidMac || $this->validMacForKey($payload, $key))
    ) {
        continue;
    }

    $decrypted = \openssl_decrypt(...);

    if ($decrypted !== false) {
        break;
    }
}

While validMacForKey() correctly uses hash_equals() for timing-safe comparison per-key, the boolean short-circuit $foundValidMac || ... creates a measurable timing difference:

  • Key is first in rotation: 1 × validMacForKey() + 1 × openssl_decrypt()
  • Key is last in rotation (N keys): N × validMacForKey() + 1 × openssl_decrypt()

The difference is (N-1) × hash_equals() time, which is measurable with enough samples.

Impact

An attacker with access to many encrypted payloads and precise timing measurements can determine which position in the key rotation was used to encrypt a given payload. During key rotation, this reveals whether data was encrypted with the old or new key — useful metadata for targeted cryptographic attacks.

Suggested Fix

Always evaluate MAC for all keys to ensure constant-time behavior regardless of key position:

$validKeyIndex = null;

foreach ($this->getAllKeys() as $index => $key) {
    if ($this->shouldValidateMac() && $this->validMacForKey($payload, $key)) {
        $validKeyIndex ??= $index;  // Record first valid, but don't short-circuit
    }
}

if ($this->shouldValidateMac() && $validKeyIndex === null) {
    throw new DecryptException('The MAC is invalid.');
}

// Now decrypt with the valid key
$decrypted = \openssl_decrypt(
    $payload['value'], strtolower($this->cipher), 
    $this->getAllKeys()[$validKeyIndex], 0, $iv, $tag ?? ''
);

Versions

  • Laravel 13.x
  • Affects any version using key rotation with APP_PREVIOUS_KEYS

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions