PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / url-presigner.php

url-presigner.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/url-presigner.php

462 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AWS s3 style Presigned URL (AWS SigV4-inspired)
4 *
5 * Usage:
6 * $signer = new UrlPresigner(KEY_ID, SECRET, 'auto', 'storeengine');
7 * $url = $signer->generateUrl('https://example.com/secure-downloads/file.zip', 300, 'GET');
8 *
9 * // In your download endpoint (front controller), call:
10 * $ok = $signer->validateCurrentRequest();
11 * if ($ok) { ... serve file ... } else { http_response_code(403); exit; }
12 */
13
14 namespace StoreEngine\Classes;
15
16 use StoreEngine\Classes\Exceptions\StoreEngineException;
17 use StoreEngine\Utils\Constants;
18 use StoreEngine\Utils\Helper;
19 use WP_Error;
20
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 class UrlPresigner {
26
27 private const OPTION_NAME = 'storeengine_url_presigner_keys';
28
29 private ?string $keyId;
30 private ?string $secret;
31
32 private ?string $payload_hash;
33 private string $region;
34 private string $service;
35 private int $maxSkewSeconds;
36
37 protected static $instance;
38
39 public static function init() {
40 return self::get_instance();
41 }
42
43 public static function get_instance() {
44 if ( null === self::$instance ) {
45 self::$instance = new self();
46 }
47
48 return self::$instance;
49 }
50
51 /**
52 * Singleton constructor.
53 *
54 * @throws Exceptions\StoreEngineException
55 */
56 protected function __construct() {
57 $this->maxSkewSeconds = 300; // ±5 minutes clock skew
58 $this->region = 'auto';
59 $this->service = 'storeengine';
60 $this->payload_hash = Constants::get_constant( 'SE_URL_PRE_SIGNER_PAYLOAD_HASH' ) ?? Constants::get_constant( 'SECURE_AUTH_KEY' );
61
62 if ( Constants::get_constant( 'SE_URL_PRE_SIGNER_KEY' ) && Constants::get_constant( 'SE_URL_PRE_SIGNER_SECRET' ) ) {
63 $this->keyId = Constants::get_constant( 'SE_URL_PRE_SIGNER_KEY' );
64 $this->secret = Constants::get_constant( 'SE_URL_PRE_SIGNER_SECRET' );
65 } else {
66 // Get Keys.
67 $keys = static::getKeys();
68
69 // Changing below payload hash will invalidate previously signed URLs.
70 $this->keyId = $keys['active']['id'];
71 $this->secret = $keys['active']['secret'];
72
73 add_action( 'init', function () {
74 // Schedule security key rotation.
75 add_action( 'storeengine/url_presigner/rotate_keys', [ __CLASS__, 'rotateKeys' ] );
76 add_action( 'storeengine/url_presigner/cleanup_keys', [ __CLASS__, 'cleanupKeys' ] );
77
78 if ( ! \StoreEngine::init()->queue()->get_next( 'storeengine/url_presigner/rotate_keys', null, 'url_presigner' ) ) {
79 \StoreEngine::init()->queue()->schedule_cron( time(), '0 0 1 * *', 'storeengine/url_presigner/rotate_keys', [], 'url_presigner' );
80 }
81 } );
82 }
83
84 if ( ! $this->payload_hash ) {
85 throw new StoreEngineException( esc_html__( 'Secure URL presigning payload hash key is not defined.', 'storeengine' ), 'payload-pre-signing-hash-missing' );
86 }
87
88 if ( ! $this->keyId || ! $this->secret ) {
89 throw new StoreEngineException( esc_html__( 'Secure URL presigning key-id & secret is not defined.', 'storeengine' ), 'payload-pre-signing-hash-missing' );
90 }
91 }
92
93 protected static function getKeys(): array {
94 $keys = get_option( self::OPTION_NAME, [] );
95
96 if ( empty( $keys ) ) {
97 $keys = [
98 'active' => self::generateKeys(),
99 'previous' => null,
100 ];
101
102 update_option( self::OPTION_NAME, $keys, false );
103 }
104
105 return wp_parse_args( $keys, [
106 'active' => null,
107 'previous' => null,
108 ] );
109 }
110
111 protected static function generateKeys(): array {
112 return [
113 'id' => 'SE-KI-X-' . wp_generate_password( 20, false, false ),
114 'secret' => wp_generate_password( 64, true, true ),
115 'created_at' => time(),
116 ];
117 }
118
119 public static function rotateKeys() {
120 $keys = self::getKeys();
121
122 // Move current active -> previous
123 if ( ! empty( $keys['active'] ) ) {
124 $keys['previous'] = $keys['active'];
125 }
126
127 // Generate new active
128 $keys['active'] = static::generateKeys();
129
130 update_option( self::OPTION_NAME, $keys, false );
131
132 // Cleanup previous-key after 10 minutes.
133 if ( ! \StoreEngine::init()->queue()->get_next( 'storeengine/url_presigner/cleanup_keys', null, 'url_presigner' ) ) {
134 \StoreEngine::init()->queue()->schedule_single( time() + ( 10 * MINUTE_IN_SECONDS ), 'storeengine/url_presigner/cleanup_keys', [], 'url_presigner' );
135 }
136 }
137
138 /**
139 * Cleanup old keys (remove previous if expired).
140 */
141 public static function cleanupKeys(): void {
142 $keys = self::getKeys();
143 $keys['previous'] = null;
144
145 update_option( self::OPTION_NAME, $keys, false );
146 }
147
148 /**
149 * Generate a pre-signed URL.
150 *
151 * @param string $url Absolute URL you’ll serve from (scheme+host+path).
152 * @param int $expiresIn Seconds from now (e.g., 300). Expire below `maxSkewSeconds` will not work.
153 * @param string $method GET/HEAD (avoid POST/PUT for downloads).
154 * @param array $headers Associative array of extra headers to sign (lowercase keys). e.g. ['x-se-user'=>'123']
155 * @param string|null $pinIp Optional IP to bind the URL to (signed as X-SE-IP).
156 */
157 public function signUrl( string $url, int $expiresIn = 300, string $method = 'GET', array $headers = [], ?string $pinIp = null ): string {
158 $parsed = $this->parseUrlStrict( $url );
159
160 $now = current_time( 'Ymd\THis\Z', 1 );
161 $date = substr( $now, 0, 8 ); // Ymd
162 $scope = $this->credentialScope( $date );
163 $qs = $this->parseQuery( $parsed['query'] ?? '' );
164
165 // Required SE query params (AWS-like)
166 $qs['X-SE-Algorithm'] = 'SE-HMAC-SHA256';
167 $qs['X-SE-Credential'] = rawurlencode( $this->keyId . '/' . $scope );
168 $qs['X-SE-Date'] = $now;
169 $qs['X-SE-Expires'] = (string) max( 1, (int) $expiresIn );
170 //$qs['X-SE-SignedHeaders'] = 'host';
171
172 if ( $pinIp ) {
173 $qs['X-SE-IP'] = $pinIp;
174 }
175
176 // Canonical headers (we sign host by default; you can add more)
177 $signedHeaders = [ 'host' => strtolower( $parsed['host'] ) ];
178 foreach ( $headers as $k => $v ) {
179 $k = strtolower( $k );
180 $signedHeaders[ $k ] = trim( (string) $v );
181 }
182 $qs['X-SE-SignedHeaders'] = implode( ';', array_keys( $signedHeaders ) );
183
184 // Build canonical request
185 $canonicalRequest = $this->canonicalRequest(
186 strtoupper( $method ),
187 $this->canonicalUri( $parsed['path'] ?? '/' ),
188 $this->canonicalQuery( $qs, /*excludeSig*/ true ),
189 $this->canonicalHeaders( $signedHeaders ),
190 implode( ';', array_keys( $signedHeaders ) )
191 );
192
193 // String to sign
194 $stringToSign = $this->stringToSign( $now, $scope, $canonicalRequest );
195
196 // Signature
197 $signingKey = $this->deriveSigningKey( $date );
198 $signature = hash_hmac( 'sha256', $stringToSign, $signingKey );
199
200 $qs['X-SE-Signature'] = $signature;
201
202 // Rebuild URL
203 return $parsed['scheme'] . '://' . $parsed['host']
204 . ( isset( $parsed['port'] ) ? ':' . $parsed['port'] : '' )
205 . $this->canonicalUri( $parsed['path'] ?? '/' )
206 . '?' . $this->canonicalQuery( $qs, /*excludeSig*/ false );
207 }
208
209 /**
210 * Validate the current HTTP request against the signature.
211 * Call this from your secure download endpoint before serving the file.
212 */
213 public function validateCurrentRequest() {
214 // Build target URL from current request.
215 $scheme = ( ! empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ? 'https' : 'http';
216 $host = wp_unslash( $_SERVER['HTTP_HOST'] ?? 'localhost' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
217 // Strip fragment
218 $uri = strtok( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ), '#' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
219 $method = wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
220 $parsed = $this->parseUrlStrict( $scheme . '://' . $host . $uri );
221 $qs = $this->parseQuery( $parsed['query'] ?? '' );
222
223 // Required Query Params.
224 $params = [
225 'X-SE-Algorithm',
226 'X-SE-Credential',
227 'X-SE-Date',
228 'X-SE-Expires',
229 'X-SE-SignedHeaders',
230 'X-SE-Signature',
231 ];
232
233 // Required params
234 foreach ( $params as $param ) {
235 if ( ! isset( $qs[ $param ] ) ) {
236 return new WP_Error( 'missing-sig-params', __( 'This download link is no longer valid.', 'storeengine' ) );
237 }
238 }
239
240 if ( $qs['X-SE-Algorithm'] !== 'SE-HMAC-SHA256' ) {
241 return new WP_Error( 'invalid-sig-algo', __( 'This download link is no longer valid.', 'storeengine' ) );
242 }
243
244 // Check expiration & clock skew
245 $requestTime = $this->parseAmzDatetime( $qs['X-SE-Date'] ); // returns UNIX timestamp
246 if ( $requestTime === null ) {
247 return new WP_Error( 'missing-sig-date', __( 'This download link is no longer valid.', 'storeengine' ) );
248 }
249
250 $expiresIn = (int) $qs['X-SE-Expires'];
251 $now = time();
252
253 if ( ( $now + $this->maxSkewSeconds ) > ( $requestTime + $expiresIn ) ) {
254 // expired
255 return new WP_Error( 'url-expired', __( 'Download link has expired. Please request a new one.', 'storeengine' ) );
256 }
257
258 if ( ( $requestTime - $this->maxSkewSeconds ) > $now ) {
259 // signed in the future beyond skew
260 return new WP_Error( 'url-expired', __( 'Download link has expired. Please request a new one.', 'storeengine' ) );
261 }
262
263 // Optional IP pinning
264 if ( ! empty( $qs['X-SE-IP'] ) ) {
265 $clientIp = Helper::get_user_ip();
266 if ( $clientIp !== $qs['X-SE-IP'] ) {
267 return new WP_Error( 'invalid-remote-ip', sprintf(
268 // translators: %s. User's IP (REMOTE_ADDR/HTTP_X_REAL_IP);
269 __( 'Access denied from this location (%s).', 'storeengine' ),
270 $clientIp
271 ) );
272 }
273 }
274
275 // Validate credential scope date/region/service
276 $credential = rawurldecode( $qs['X-SE-Credential'] );
277 // expected format: <keyId>/<date>/<region>/<service>/SE-Secure-Request
278 $parts = explode( '/', $credential );
279
280 if ( count( $parts ) !== 5 || $parts[0] !== $this->keyId || $parts[4] !== 'SE-Secure-Request' ) {
281 return new WP_Error( 'sig-parts-mismatched', __( 'This download link is no longer valid.', 'storeengine' ) );
282 }
283
284 [ $keyId, $date, $region, $service, $term ] = $parts;
285
286 if ( $region !== $this->region || $service !== $this->service ) {
287 return new WP_Error( 'invalid-sig-service-region', __( 'This download link is no longer valid.', 'storeengine' ) );
288 }
289
290
291 // Rebuild signed headers from request
292 $signedHeaderNames = explode( ';', strtolower( $qs['X-SE-SignedHeaders'] ) );
293 $signedHeaders = [];
294
295 foreach ( $signedHeaderNames as $h ) {
296 if ( $h === '' ) {
297 continue;
298 }
299 if ( $h === 'host' ) {
300 $signedHeaders['host'] = strtolower( $parsed['host'] );
301 } else {
302 // Pull from HTTP_* server vars
303 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $h ) );
304 if ( ! isset( $_SERVER[ $key ] ) ) {
305 // header missing -> fail
306 return false;
307 }
308 $signedHeaders[ $h ] = trim( $_SERVER[ $key ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Using for signature validation, not for rendering.
309 }
310 }
311
312 // Build canonical request (exclude signature)
313 $qsForSigning = $qs;
314 unset( $qsForSigning['X-SE-Signature'] );
315
316 $canonicalRequest = $this->canonicalRequest(
317 strtoupper( $method ),
318 $this->canonicalUri( $parsed['path'] ?? '/' ),
319 $this->canonicalQuery( $qsForSigning, true ),
320 $this->canonicalHeaders( $signedHeaders ),
321 implode( ';', array_keys( $signedHeaders ) )
322 );
323
324 $scope = $this->credentialScope( $date );
325 $stringToSign = $this->stringToSign( gmdate( 'Ymd\THis\Z', $requestTime ), $scope, $canonicalRequest );
326 $signingKey = $this->deriveSigningKey( $date );
327 $expectedSig = hash_hmac( 'sha256', $stringToSign, $signingKey );
328
329 if ( ! hash_equals( $expectedSig, $qs['X-SE-Signature'] ) ) {
330 return new WP_Error( 'invalid-sig', __( 'Signature verification failed.', 'storeengine' ) );
331 }
332
333 return true;
334 }
335
336 // ===== Helpers =====
337
338 private function parseUrlStrict( string $url ): array {
339 $p = wp_parse_url( $url );
340 if ( ! $p || empty( $p['scheme'] ) || empty( $p['host'] ) ) {
341 throw new \InvalidArgumentException( 'URL must include scheme and host.' );
342 }
343 if ( ! isset( $p['path'] ) ) {
344 $p['path'] = '/';
345 }
346
347 return $p;
348 }
349
350 private function parseQuery( string $query ): array {
351 $out = [];
352 if ( $query !== '' ) {
353 foreach ( explode( '&', $query ) as $pair ) {
354 if ( $pair === '' ) {
355 continue;
356 }
357
358 [
359 $key,
360 $value,
361 ] = array_pad( explode( '=', $pair, 2 ), 2, '' );
362
363 $out[ rawurldecode( $key ) ] = rawurldecode( $value );
364 }
365 }
366
367 return $out;
368 }
369
370 private function canonicalUri( string $path ): string {
371 // Normalize each segment like AWS (double-encode reserved chars)
372 $segments = explode( '/', $path );
373 $enc = array_map( fn( $s ) => implode( '%20', array_map( 'rawurlencode', explode( ' ', $s ) ) ), $segments );
374
375 return implode( '/', $enc ) ?: '/';
376 }
377
378 private function canonicalQuery( array $params, bool $excludeSig ): string {
379 if ( $excludeSig ) {
380 unset( $params['X-SE-Signature'] );
381 }
382 ksort( $params, SORT_STRING );
383 $pairs = [];
384 foreach ( $params as $k => $v ) {
385 $pairs[] = rawurlencode( (string) $k ) . '=' . rawurlencode( (string) $v );
386 }
387
388 return implode( '&', $pairs );
389 }
390
391 private function canonicalHeaders( array $headers ): string {
392 ksort( $headers, SORT_STRING );
393 $lines = [];
394 foreach ( $headers as $k => $v ) {
395 $v = preg_replace( '/\s+/', ' ', trim( (string) $v ) );
396 $lines[] = strtolower( $k ) . ':' . $v;
397 }
398
399 return implode( "\n", $lines ) . "\n";
400 }
401
402 private function canonicalRequest( string $method, string $uri, string $query, string $headers, string $signedHeaders ): string {
403 return implode( "\n", [
404 $method,
405 $uri,
406 $query,
407 $headers,
408 $signedHeaders,
409 hash( 'sha256', $this->payload_hash ),
410 ] );
411 }
412
413 private function stringToSign( string $amzDatetime, string $scope, string $canonicalRequest ): string {
414 return "SE-HMAC-SHA256\n" .
415 $amzDatetime . "\n" .
416 $scope . "\n" .
417 hash( 'sha256', $canonicalRequest );
418 }
419
420 private function credentialScope( string $date ): string {
421 // <date>/<region>/<service>/SE-Secure-Request (AWS-like)
422 return $date . '/' . $this->region . '/' . $this->service . '/SE-Secure-Request';
423 }
424
425 private function deriveSigningKey( string $date ): string {
426 // AWS-like key ladder: kDate = HMAC("STOREENGINE4".$secret, date) ...
427 $kSecret = 'STOREENGINE4' . $this->secret;
428 $kDate = hash_hmac( 'sha256', $date, $kSecret, true );
429 $kRegion = hash_hmac( 'sha256', $this->region, $kDate, true );
430 $kSvc = hash_hmac( 'sha256', $this->service, $kRegion, true );
431
432 return hash_hmac( 'sha256', 'SE-Secure-Request', $kSvc, true );
433 }
434
435 private function parseAmzDatetime( string $s ): ?int {
436 // Expect: YYYYMMDDTHHMMSSZ
437 if ( ! preg_match( '/^\d{8}T\d{6}Z$/', $s ) ) {
438 return null;
439 }
440
441 $dt = \DateTime::createFromFormat( 'Ymd\THis\Z', $s, new \DateTimeZone( 'UTC' ) );
442
443 return $dt ? $dt->getTimestamp() : null;
444 }
445
446 /**
447 * Cloning is forbidden.
448 */
449 public function __clone() {
450 _doing_it_wrong( __FUNCTION__, esc_html__( 'Cloning is forbidden.', 'storeengine' ), '0.0.4' );
451 }
452
453 /**
454 * Unserializing instances of this class is forbidden.
455 */
456 public function __wakeup() {
457 _doing_it_wrong( __FUNCTION__, esc_html__( 'Unserializing instances of this class is forbidden.', 'storeengine' ), '0.0.4' );
458 }
459 }
460
461 // End of file url-presigner.php.
462