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

  1. Make config secret_key equal the current APIv3 key — exactly 32 chars, trim() it.
  2. Pass nonce and associated_data exactly as received in resource (e.g. 'transaction' or 'refund').
  3. Validate before calling: base64-decoded ciphertext must be longer than 16 bytes and strlen($key) === 32.
  4. 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

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.