Compare commits
5
Commits
9f513a819e
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc838ada7 | ||
|
|
a11aa4260a | ||
|
|
8062e1be77 | ||
|
|
64d215cb26 | ||
|
|
fa748d61d3 |
No files matched your search
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-01-26
|
||||
|
||||
### Added
|
||||
|
||||
- SSRF protection with URL validation and private IP range blocking
|
||||
- `allowInsecureHttp` constructor parameter for development environments
|
||||
- Input validation in all DTO `fromArray()` methods
|
||||
- DateTime exception handling in DTOs
|
||||
- Recursive key sorting in `ResponseSignature` for nested objects
|
||||
|
||||
### Changed
|
||||
|
||||
- Key derivation now uses RFC 5869 compliant `hash_hkdf()` instead of custom HMAC
|
||||
- Exception messages sanitized to prevent information disclosure
|
||||
- Header normalization treats empty values as null
|
||||
|
||||
### Fixed
|
||||
|
||||
- JSON encoding error handling in `ResponseSignature::buildSignaturePayload()`
|
||||
- Header normalization null risk in `SecureLicenseClient`
|
||||
|
||||
### Security
|
||||
|
||||
- Comprehensive security audit performed
|
||||
- SSRF vulnerability mitigated
|
||||
- Information disclosure in error messages fixed
|
||||
- Improved cryptographic key derivation
|
||||
|
||||
## [0.1.0] - 2026-01-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -29,13 +29,13 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
No known bugs at the moment
|
||||
|
||||
### Version 0.1.1
|
||||
### Version 0.2.1
|
||||
|
||||
No changes at the moment
|
||||
No pending tasks at the moment.
|
||||
|
||||
### Version 0.2.0
|
||||
### Version 0.3.0
|
||||
|
||||
No changes at the moment.
|
||||
No pending tasks at the moment.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -181,3 +181,44 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint
|
||||
- Client documentation complements server documentation for complete integration guide
|
||||
- Integration examples for major PHP frameworks help adoption
|
||||
- Error code mapping to exception classes aids programmatic error handling
|
||||
|
||||
### 2026-01-24 - Security Audit and Fixes
|
||||
|
||||
**Completed:**
|
||||
|
||||
- Performed comprehensive security audit of entire codebase
|
||||
- Fixed JSON encoding error handling in `ResponseSignature::buildSignaturePayload()`
|
||||
- Sanitized exception messages in both client classes to prevent information disclosure
|
||||
- Fixed header normalization to treat empty values as null in `SecureLicenseClient`
|
||||
- Added SSRF protection with URL validation and private IP range blocking
|
||||
- Replaced custom key derivation with RFC 5869 compliant `hash_hkdf()`
|
||||
- Added input validation in all DTO `fromArray()` methods
|
||||
- Added DateTime exception handling in DTOs to prevent uncaught exceptions
|
||||
- Added new `allowInsecureHttp` constructor parameter for development environments
|
||||
|
||||
**Learnings:**
|
||||
|
||||
- Security audit identified 7 fixable issues across critical, high, and medium priority
|
||||
- `hash_hkdf()` is PHP's native RFC 5869 implementation - prefer it over custom HKDF
|
||||
- SSRF protection requires: URL scheme validation, private IP blocking, DNS resolution checks
|
||||
- Exception messages should never expose internal details to end users
|
||||
- DTO validation should check both existence (`isset`) and type (`is_int`, `is_bool`, etc.)
|
||||
- Empty header values should be treated as missing (null) not empty strings
|
||||
- Constructor parameters added: `allowInsecureHttp` for HTTP on non-localhost in dev mode
|
||||
- Private IP ranges to block: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 0.0.0.0/8
|
||||
|
||||
### 2026-01-26 - Server Implementation Alignment
|
||||
|
||||
**Completed:**
|
||||
|
||||
- Verified client implementation against server documentation
|
||||
- Updated server docs to use RFC 5869 `hash_hkdf()` for key derivation (matching client)
|
||||
- Added recursive key sorting (`sortKeysRecursive()`) to client `ResponseSignature`
|
||||
- Client and server now use identical signature algorithms
|
||||
|
||||
**Learnings:**
|
||||
|
||||
- Server and client must use identical key derivation and JSON canonicalization
|
||||
- Recursive key sorting is essential for nested objects like the `license` object in validate responses
|
||||
- When updating cryptographic implementations, both client and server documentation must be aligned
|
||||
- The remote server documentation URL was 404 - local `docs/server-implementation.md` is the source of truth
|
||||
@@ -188,22 +188,28 @@ Also include the `Retry-After` HTTP header.
|
||||
|
||||
### Key Derivation
|
||||
|
||||
Each license key gets a unique signing key derived from the server secret:
|
||||
Each license key gets a unique signing key derived from the server secret using RFC 5869 HKDF:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Derive a unique signing key for a license.
|
||||
*
|
||||
* Uses RFC 5869 HKDF for secure key derivation.
|
||||
*
|
||||
* @param string $licenseKey The license key
|
||||
* @param string $serverSecret The server's master secret
|
||||
* @return string The derived key (hex encoded)
|
||||
* @return string The derived key (hex encoded, 64 chars)
|
||||
*/
|
||||
function derive_signing_key(string $licenseKey, string $serverSecret): string
|
||||
{
|
||||
// HKDF-like key derivation
|
||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
||||
// Use PHP's native HKDF implementation (RFC 5869)
|
||||
// - IKM (input keying material): server secret
|
||||
// - Length: 32 bytes (256 bits for SHA-256)
|
||||
// - Info: license key (context-specific info)
|
||||
// - Salt: empty (uses hash-length zero bytes as per RFC 5869)
|
||||
$binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
||||
return bin2hex($binaryKey);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -278,7 +284,7 @@ signature = HMAC-SHA256(
|
||||
|
||||
Where:
|
||||
|
||||
- `derive_signing_key` uses HKDF-like derivation
|
||||
- `derive_signing_key` uses RFC 5869 HKDF via `hash_hkdf()`
|
||||
- `canonical_json` sorts keys recursively, uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`
|
||||
- Result is hex-encoded (64 characters)
|
||||
|
||||
@@ -678,9 +684,10 @@ final class LicenseApi
|
||||
|
||||
private function deriveKey(string $licenseKey): string
|
||||
{
|
||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
||||
// RFC 5869 HKDF key derivation
|
||||
$binaryKey = hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
||||
return bin2hex($binaryKey);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -14,6 +14,13 @@ final readonly class ActivationResult
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
if (!isset($data['success']) || !is_bool($data['success'])) {
|
||||
throw new \InvalidArgumentException('Invalid response: missing or invalid success field');
|
||||
}
|
||||
if (!isset($data['message']) || !is_string($data['message'])) {
|
||||
throw new \InvalidArgumentException('Invalid response: missing or invalid message field');
|
||||
}
|
||||
|
||||
return new self(
|
||||
success: $data['success'],
|
||||
message: $data['message'],
|
||||
|
||||
+13
-1
@@ -15,9 +15,21 @@ final readonly class LicenseInfo
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
if (!isset($data['product_id']) || !is_int($data['product_id'])) {
|
||||
throw new \InvalidArgumentException('Invalid response: missing or invalid product_id');
|
||||
}
|
||||
|
||||
$expiresAt = null;
|
||||
if (isset($data['expires_at']) && $data['expires_at'] !== null) {
|
||||
$expiresAt = new \DateTimeImmutable($data['expires_at']);
|
||||
try {
|
||||
$expiresAt = new \DateTimeImmutable($data['expires_at']);
|
||||
} catch (\Exception $e) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Invalid response: invalid date format for expires_at',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
|
||||
@@ -26,14 +26,49 @@ final readonly class LicenseStatus
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
// Validate required fields
|
||||
if (!isset($data['valid']) || !is_bool($data['valid'])) {
|
||||
throw new \InvalidArgumentException('Invalid response: missing or invalid valid field');
|
||||
}
|
||||
if (!isset($data['status']) || !is_string($data['status'])) {
|
||||
throw new \InvalidArgumentException('Invalid response: missing or invalid status field');
|
||||
}
|
||||
if (!isset($data['domain']) || !is_string($data['domain'])) {
|
||||
throw new \InvalidArgumentException('Invalid response: missing or invalid domain field');
|
||||
}
|
||||
if (!isset($data['activations_count']) || !is_int($data['activations_count'])) {
|
||||
throw new \InvalidArgumentException('Invalid response: missing or invalid activations_count field');
|
||||
}
|
||||
if (!isset($data['max_activations']) || !is_int($data['max_activations'])) {
|
||||
throw new \InvalidArgumentException('Invalid response: missing or invalid max_activations field');
|
||||
}
|
||||
|
||||
$expiresAt = null;
|
||||
if (isset($data['expires_at']) && $data['expires_at'] !== null) {
|
||||
$expiresAt = new \DateTimeImmutable($data['expires_at']);
|
||||
try {
|
||||
$expiresAt = new \DateTimeImmutable($data['expires_at']);
|
||||
} catch (\Exception $e) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Invalid response: invalid date format for expires_at',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$status = LicenseState::from($data['status']);
|
||||
} catch (\ValueError $e) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Invalid response: unknown license status value',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
|
||||
return new self(
|
||||
valid: $data['valid'],
|
||||
status: LicenseState::from($data['status']),
|
||||
status: $status,
|
||||
domain: $data['domain'],
|
||||
expiresAt: $expiresAt,
|
||||
activationsCount: $data['activations_count'],
|
||||
|
||||
+81
-2
@@ -18,6 +18,16 @@ final class LicenseClient implements LicenseClientInterface
|
||||
private const API_PATH = '/wp-json/wc-licensed-product/v1';
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/** @var string[] Private IPv4 ranges (CIDR notation) */
|
||||
private const PRIVATE_IP_RANGES = [
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'0.0.0.0/8',
|
||||
];
|
||||
|
||||
private readonly LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
@@ -26,8 +36,10 @@ final class LicenseClient implements LicenseClientInterface
|
||||
?LoggerInterface $logger = null,
|
||||
private readonly ?CacheItemPoolInterface $cache = null,
|
||||
private readonly int $cacheTtl = self::CACHE_TTL,
|
||||
bool $allowInsecureHttp = false,
|
||||
) {
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
$this->validateBaseUrl($baseUrl, $allowInsecureHttp);
|
||||
}
|
||||
|
||||
public function validate(string $licenseKey, string $domain): LicenseInfo
|
||||
@@ -165,13 +177,15 @@ final class LicenseClient implements LicenseClientInterface
|
||||
} catch (LicenseException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
// Log full error for debugging but sanitize for user-facing message
|
||||
$this->logger->error('License API request failed', [
|
||||
'endpoint' => $endpoint,
|
||||
'error' => $e->getMessage(),
|
||||
'exception_class' => $e::class,
|
||||
'error_code' => $e->getCode(),
|
||||
]);
|
||||
|
||||
throw new LicenseException(
|
||||
'Failed to communicate with license server: ' . $e->getMessage(),
|
||||
'Failed to communicate with license server',
|
||||
null,
|
||||
0,
|
||||
$e
|
||||
@@ -187,4 +201,69 @@ final class LicenseClient implements LicenseClientInterface
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the base URL to prevent SSRF attacks.
|
||||
*
|
||||
* @throws \InvalidArgumentException If the URL is invalid or potentially dangerous
|
||||
*/
|
||||
private function validateBaseUrl(string $url, bool $allowInsecureHttp): void
|
||||
{
|
||||
if ($url === '') {
|
||||
throw new \InvalidArgumentException('Base URL cannot be empty');
|
||||
}
|
||||
|
||||
$parsed = parse_url($url);
|
||||
if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
|
||||
throw new \InvalidArgumentException('Invalid base URL format');
|
||||
}
|
||||
|
||||
$scheme = strtolower($parsed['scheme']);
|
||||
$host = strtolower($parsed['host']);
|
||||
|
||||
// Require HTTPS unless explicitly allowed for localhost
|
||||
if ($scheme !== 'https') {
|
||||
if ($scheme !== 'http') {
|
||||
throw new \InvalidArgumentException('Base URL must use HTTP or HTTPS scheme');
|
||||
}
|
||||
$isLocalhost = $host === 'localhost' || $host === '127.0.0.1';
|
||||
if (!$allowInsecureHttp && !$isLocalhost) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Base URL must use HTTPS for non-localhost hosts. ' .
|
||||
'Set allowInsecureHttp=true to allow HTTP (not recommended for production).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve hostname and check for private IPs
|
||||
$ip = gethostbyname($host);
|
||||
if ($ip !== $host && $this->isPrivateIp($ip)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Base URL resolves to a private IP address, which is not allowed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is in a private range.
|
||||
*/
|
||||
private function isPrivateIp(string $ip): bool
|
||||
{
|
||||
$ipLong = ip2long($ip);
|
||||
if ($ipLong === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::PRIVATE_IP_RANGES as $range) {
|
||||
[$subnet, $bits] = explode('/', $range);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,16 @@ final class SecureLicenseClient implements LicenseClientInterface
|
||||
{
|
||||
private const CACHE_TTL = 300;
|
||||
|
||||
/** @var string[] Private IPv4 ranges (CIDR notation) */
|
||||
private const PRIVATE_IP_RANGES = [
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'0.0.0.0/8',
|
||||
];
|
||||
|
||||
private readonly LoggerInterface $logger;
|
||||
private readonly StringEncoder $encoder;
|
||||
private readonly string $apiPath;
|
||||
@@ -52,10 +62,12 @@ final class SecureLicenseClient implements LicenseClientInterface
|
||||
private readonly int $cacheTtl = self::CACHE_TTL,
|
||||
private readonly bool $verifyIntegrity = false,
|
||||
?StringEncoder $encoder = null,
|
||||
bool $allowInsecureHttp = false,
|
||||
) {
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
$this->encoder = $encoder ?? new StringEncoder();
|
||||
$this->apiPath = $this->encoder->decode(self::ENCODED_API_PATH);
|
||||
$this->validateBaseUrl($baseUrl, $allowInsecureHttp);
|
||||
|
||||
if ($this->verifyIntegrity) {
|
||||
$this->checkIntegrity();
|
||||
@@ -199,13 +211,15 @@ final class SecureLicenseClient implements LicenseClientInterface
|
||||
} catch (LicenseException | SignatureException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
// Log full error for debugging but sanitize for user-facing message
|
||||
$this->logger->error('License API request failed', [
|
||||
'endpoint' => $endpoint,
|
||||
'error' => $e->getMessage(),
|
||||
'exception_class' => $e::class,
|
||||
'error_code' => $e->getCode(),
|
||||
]);
|
||||
|
||||
throw new LicenseException(
|
||||
'Failed to communicate with license server: ' . $e->getMessage(),
|
||||
'Failed to communicate with license server',
|
||||
null,
|
||||
0,
|
||||
$e
|
||||
@@ -239,7 +253,13 @@ final class SecureLicenseClient implements LicenseClientInterface
|
||||
|
||||
foreach ($headers as $name => $values) {
|
||||
// HTTP client returns arrays of values; take the first one
|
||||
$normalized[$name] = is_array($values) ? ($values[0] ?? '') : $values;
|
||||
// Empty arrays or empty strings should be treated as missing (null)
|
||||
if (is_array($values)) {
|
||||
$value = $values[0] ?? null;
|
||||
$normalized[$name] = ($value !== '' && $value !== null) ? $value : null;
|
||||
} else {
|
||||
$normalized[$name] = ($values !== '' && $values !== null) ? $values : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
@@ -298,4 +318,69 @@ final class SecureLicenseClient implements LicenseClientInterface
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the base URL to prevent SSRF attacks.
|
||||
*
|
||||
* @throws \InvalidArgumentException If the URL is invalid or potentially dangerous
|
||||
*/
|
||||
private function validateBaseUrl(string $url, bool $allowInsecureHttp): void
|
||||
{
|
||||
if ($url === '') {
|
||||
throw new \InvalidArgumentException('Base URL cannot be empty');
|
||||
}
|
||||
|
||||
$parsed = parse_url($url);
|
||||
if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
|
||||
throw new \InvalidArgumentException('Invalid base URL format');
|
||||
}
|
||||
|
||||
$scheme = strtolower($parsed['scheme']);
|
||||
$host = strtolower($parsed['host']);
|
||||
|
||||
// Require HTTPS unless explicitly allowed for localhost
|
||||
if ($scheme !== 'https') {
|
||||
if ($scheme !== 'http') {
|
||||
throw new \InvalidArgumentException('Base URL must use HTTP or HTTPS scheme');
|
||||
}
|
||||
$isLocalhost = $host === 'localhost' || $host === '127.0.0.1';
|
||||
if (!$allowInsecureHttp && !$isLocalhost) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Base URL must use HTTPS for non-localhost hosts. ' .
|
||||
'Set allowInsecureHttp=true to allow HTTP (not recommended for production).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve hostname and check for private IPs
|
||||
$ip = gethostbyname($host);
|
||||
if ($ip !== $host && $this->isPrivateIp($ip)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Base URL resolves to a private IP address, which is not allowed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is in a private range.
|
||||
*/
|
||||
private function isPrivateIp(string $ip): bool
|
||||
{
|
||||
$ipLong = ip2long($ip);
|
||||
if ($ipLong === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::PRIVATE_IP_RANGES as $range) {
|
||||
[$subnet, $bits] = explode('/', $range);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -112,28 +112,55 @@ final class ResponseSignature
|
||||
/**
|
||||
* Derive a unique key from the license key and server secret.
|
||||
*
|
||||
* Uses HKDF-like key derivation to create a unique key per license.
|
||||
* Uses RFC 5869 HKDF to create a unique key per license.
|
||||
*/
|
||||
public static function deriveKey(string $licenseKey, string $serverSecret): string
|
||||
{
|
||||
// Use HKDF expansion with license key as info
|
||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
||||
// Use PHP's native HKDF implementation (RFC 5869)
|
||||
// - IKM (input keying material): server secret
|
||||
// - Length: 32 bytes (256 bits for SHA-256)
|
||||
// - Info: license key (context-specific info)
|
||||
// - Salt: empty (uses hash-length zero bytes as per RFC 5869)
|
||||
$binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
||||
// Convert to hex for consistent string handling
|
||||
return bin2hex($binaryKey);
|
||||
}
|
||||
|
||||
private function buildSignaturePayload(array $responseData, int $timestamp): string
|
||||
{
|
||||
// Sort keys for consistent ordering
|
||||
ksort($responseData);
|
||||
// Sort keys recursively for consistent ordering (matches server implementation)
|
||||
$sortedData = $this->sortKeysRecursive($responseData);
|
||||
|
||||
// Create deterministic JSON representation
|
||||
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$jsonBody = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($jsonBody === false) {
|
||||
throw new \RuntimeException(
|
||||
'Failed to encode response data for signature verification: ' . json_last_error_msg()
|
||||
);
|
||||
}
|
||||
|
||||
// Combine timestamp and body for signature
|
||||
return $timestamp . ':' . $jsonBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort array keys for consistent JSON output.
|
||||
*/
|
||||
private function sortKeysRecursive(array $data): array
|
||||
{
|
||||
ksort($data);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->sortKeysRecursive($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function isTimestampValid(int $timestamp): bool
|
||||
{
|
||||
$now = time();
|
||||
|
||||
Reference in new issue
Block a user