{"record":{"id":"df9728d106811450","repo":"w7corp/easywechat","slug":"clock-offset-exceeded","errorCode":null,"errorMessage":"Clock Offset Exceeded","messagePattern":"Clock Offset Exceeded","errorType":"exception","errorClass":"InvalidSignatureException","httpStatus":null,"severity":"error","filePath":"src/Pay/Validator.php","lineNumber":50,"sourceCode":"    public function validate(MessageInterface $message): void\n    {\n        foreach ([self::HEADER_SIGNATURE, self::HEADER_TIMESTAMP, self::HEADER_SERIAL, self::HEADER_NONCE] as $header) {\n            if (! $message->hasHeader($header)) {\n                throw new InvalidSignatureException(\"Missing Header: {$header}\");\n            }\n        }\n\n        [$timestamp] = $message->getHeader(self::HEADER_TIMESTAMP);\n        [$nonce] = $message->getHeader(self::HEADER_NONCE);\n        [$serial] = $message->getHeader(self::HEADER_SERIAL);\n        [$signature] = $message->getHeader(self::HEADER_SIGNATURE);\n\n        $body = (string) $message->getBody();\n\n        $message = \"{$timestamp}\\n{$nonce}\\n{$body}\\n\";\n\n        if (\\time() - \\intval($timestamp) > self::MAX_ALLOWED_CLOCK_OFFSET) {\n            throw new InvalidSignatureException('Clock Offset Exceeded');\n        }\n\n        $publicKey = $this->merchant->getPlatformCert($serial);\n\n        if (! $publicKey) {\n            throw new InvalidConfigException(\n                \"No platform certs found for serial: {$serial}, \n                please download from wechat pay and set it in merchant config with key `certs`.\"\n            );\n        }\n\n        if (\\openssl_verify(\n            $message,\n            base64_decode($signature),\n            strval($publicKey),\n            OPENSSL_ALGO_SHA256\n        ) !== 1) {\n            throw new InvalidSignatureException('Invalid Signature');","sourceCodeStart":32,"sourceCodeEnd":68,"githubUrl":"https://github.com/w7corp/easywechat/blob/f0cf0a8b8361417ed683b8246d0ecbaf0aafcaa8/src/Pay/Validator.php#L32-L68","documentation":"Replay protection in Validator::validate() (src/Pay/Validator.php:49-51): every signed message carries Wechatpay-Timestamp, and the validator rejects anything where time() - intval($timestamp) exceeds MAX_ALLOWED_CLOCK_OFFSET = 300 seconds (Validator.php:14). It is raised as InvalidSignatureException even when the signature itself is valid, because a stale timestamp can indicate a replayed notification. The check is one-directional — only past-dated timestamps are rejected; future timestamps pass.","triggerScenarios":"Local server clock behind real time so genuine notifications look older than 5 minutes; validating a webhook long after receipt (queue worker picks it up 10 minutes later, retry backlog, crashed workers resumed); replaying captured requests through the validator during debugging; WeChat re-delivering an old notification after a long endpoint outage.","commonSituations":"Containers or VMs without NTP (chrony/ntpd/systemd-timesyncd blocked in the network policy); store-now-validate-later webhook architectures; queue lag exceeding 300 seconds; reprocessing archived notifications in a test or migration environment.","solutions":["Validate at ingress: run the Validator/Server flow inside the HTTP handler immediately on receipt, then enqueue the already-verified message.","Sync the clock on every host and container: enable chrony, ntpd or systemd-timesyncd and verify with `date -u` against world time.","If validation must happen late, treat the stale notification as untrusted and confirm the real order state via the WeChat Pay query API instead of trusting the old body.","Monitor webhook queue lag and alert well before it approaches the 300-second window."],"exampleFix":"// before: validate long after receipt (queue worker runs 10 min later)\n$storedRequest = $cache->get('wechat_notify_' . $id);\n$app->getValidator()->validate($storedRequest); // Clock Offset Exceeded\n\n// after: validate immediately in the HTTP handler, queue only the verified message\n$app->getValidator()->validate($request); // ingress, within seconds of delivery\n$queue->push(ProcessWechatNotify::class, ['body' => (string) $request->getBody()]);","handlingStrategy":"validation","validationCode":"$timestamp = (int) $request->getHeaderLine('Wechatpay-Timestamp');\n\nif ($timestamp <= 0 || (time() - $timestamp) > 300) {\n    // Stale/replayed or clock-drifted: reject before full validation\n    return new \\GuzzleHttp\\Psr7\\Response(400);\n}\n\n$app->getValidator()->validate($request);","typeGuard":null,"tryCatchPattern":"use EasyWeChat\\Pay\\Exceptions\\InvalidSignatureException;\n\ntry {\n    $app->getValidator()->validate($request);\n} catch (InvalidSignatureException $e) {\n    if (str_contains($e->getMessage(), 'Clock Offset')) {\n        // Late processing or clock drift: don't trust the stored message —\n        // query the order via the WeChat Pay API for truth, then act on that.\n        return confirmOrderStateViaApi($request);\n    }\n\n    return $response->withStatus(401);\n}","preventionTips":["Run chrony/ntpd or systemd-timesyncd on every app host and container","Validate webhooks at ingress; enqueue only after validation succeeds","Monitor webhook queue lag and alert well before it approaches 300 seconds","Never replay captured notifications against the live validator — Clock Offset Exceeded is the by-design result"],"tags":["wechat-pay","webhook","clock-skew","replay-protection","php"],"backgroundTag":"clock-skew","analyzedSha":"f0cf0a8b8361417ed683b8246d0ecbaf0aafcaa8","analyzedAt":"2026-08-21T05:29:19.565Z","schemaVersion":2},"datasetVersion":"2026-08-21T11:28:35.574Z"}