w7corp/easywechat · error · InvalidArgumentException
Decrypt failed
Error message
Decrypt failed
What it means
Thrown when openssl_decrypt() returns false for aes-256-gcm after the class splits the last 16 bytes (AesGcm::BLOCK_SIZE) off the base64-decoded payload as the auth tag. Internally Pay\Server::decodeJsonMessage/decodeXmlMessage (src/Pay/Server.php:238,278) call it with the merchant secretKey (APIv3 key), the notification nonce and associated_data. GCM authentication fails if any byte is wrong: bad key length or value, wrong nonce, wrong AAD, or a ciphertext shorter than 16 bytes.
Source
Thrown at src/Kernel/Support/AesGcm.php:57
return base64_encode($ciphertext.$tag);
}
/**
* @throws InvalidArgumentException
*/
public static function decrypt(string $ciphertext, string $key, ?string $iv = null, string $aad = ''): string
{
$ciphertext = base64_decode($ciphertext);
$tag = substr($ciphertext, -self::BLOCK_SIZE);
$ciphertext = substr($ciphertext, 0, -self::BLOCK_SIZE);
$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, (string) $iv, $tag, $aad);
if ($plaintext === false) {
throw new InvalidArgumentException(openssl_error_string() ?: 'Decrypt failed');
}
return $plaintext;
}
}
View on GitHub (pinned to f0cf0a8b83)
Solutions
- Make config secret_key equal the current APIv3 key — exactly 32 chars, trim() it.
- Pass nonce and associated_data exactly as received in resource (e.g. 'transaction' or 'refund').
- Validate before calling: base64-decoded ciphertext must be longer than 16 bytes and strlen($key) === 32.
- If the key was rotated, keep the old key available until pending notifications are drained, or re-download platform certs and reprocess.
Example fix
// before: stale/short APIv3 key straight from config
$merchant = new Merchant(..., secretKey: $config['secret_key']);
// after: validate the 32-char key before building the pay app
$secretKey = trim((string) $config['secret_key']);
if (strlen($secretKey) !== 32) { throw new RuntimeException('APIv3 key must be 32 chars'); }
$app = new Pay\Application([...defaults, 'secret_key' => $secretKey]); Defensive patterns
Strategy: validation
Validate before calling
$raw = base64_decode($ciphertext, true);
if ($raw === false || strlen($raw) <= 16) { throw new RuntimeException('ciphertext must be base64 and longer than the 16-byte tag'); }
if (strlen($secretKey) !== 32) { throw new RuntimeException('APIv3 key must be exactly 32 chars'); } Try / catch
try { $plain = AesGcm::decrypt($ciphertext, $secretKey, $nonce, $aad); } catch (\EasyWeChat\Kernel\Exceptions\InvalidArgumentException $e) { \Log::error('pay notification decrypt failed', ['err' => $e->getMessage(), 'key_len' => strlen($secretKey)]); return new \Nyholm\Psr7\Response(500); // non-2xx makes WeChat redeliver } Prevention
- Rotate APIv3 keys together with app config in one deploy
- Log key length (never the key) on failure
- Never mix nonce/associated_data across notifications
When it happens
Trigger: Processing a WeChat Pay v3 notification with a secret_key that is not the current 32-char APIv3 key; swapping nonce or associated_data between different notifications; resource.ciphertext shorter than 16 decoded bytes (tag substr() eats the whole string); config value with a trailing newline making the key 33 chars.
Common situations: APIv3 key rotated in the merchant console but the app config still holds the old one; staging and production keys swapped; notification JSON re-parsed/logged and associated_data lost; notifications delivered after a key change encrypted with the previous key.
Related errors
AI-assisted analysis of w7corp/easywechat@f0cf0a8b83 (2026-08-21).
Data as JSON: /api/errors/9bcb2b901d2c00b9.
Report an issue: GitHub.