From 8f61cd7b538b9fe0da46273817d9bf1dbdf51d26 Mon Sep 17 00:00:00 2001 From: Watson Zuo <hx.zuo@aftership.com> Date: Fri, 17 Mar 2023 11:43:40 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20api=20header=20and?= =?UTF-8?q?=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add as-api-key and tracking mark as completed api --- composer.json | 3 +- src/Encryption.php | 81 ++++++++++++++++++++++ src/Request.php | 169 +++++++++++++++++++++++++++++++++++++++++---- src/Trackings.php | 38 ++++++++++ 4 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 src/Encryption.php diff --git a/composer.json b/composer.json index 384e39e..50b1802 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ } ], "require": { - "php": ">=5.4.0" + "php": ">=5.4.0", + "phpseclib/phpseclib": "~2.0" }, "require-dev": { "phpunit/phpunit": "~6.2", diff --git a/src/Encryption.php b/src/Encryption.php new file mode 100644 index 0000000..a5122b4 --- /dev/null +++ b/src/Encryption.php @@ -0,0 +1,81 @@ +<?php + +namespace AfterShip; + +use phpseclib\Crypt\RSA; + +/** + * Class Encryption + * @package AfterShip + */ +class Encryption +{ + const ENCRYPTION_RSA = 'RSA'; + + const ENCRYPTION_AES = 'AES'; + + /** + * @var string + */ + private $apiSecret = ''; + /** + * @var string + */ + private $encryptionMethod = ''; + /** + * @var string + */ + private $encryptionPassword = ''; + /** + * Request constructor. + * + * @param $apiSecret the private key + * @param $encryptionMethod 'RSA' or 'AES' + * @param $encryptionPassword the private key password + */ + function __construct($apiSecret, $encryptionMethod, $encryptionPassword) + { + $this->apiSecret = $apiSecret; + $this->encryptionMethod = $encryptionMethod; + $this->encryptionPassword = $encryptionPassword; + } + + /** + * Calculate the digest of data with RSA_SIGN_PSS_2048_SHA256 algorithm. + * https://www.aftership.com/docs/tracking/quickstart/authentication#3-rsa + * @param $data The data to be encrypted. + * @return string the encrypted result with base64 encode + * @throws AfterShipException + */ + function rsaPSSSha256Encrypt($data) { + $rsa = new RSA(); + if (!empty($this->encryptionPassword )) { + $rsa->setPassword($this->encryptionPassword); + } + $loadResult = $rsa->loadKey($this->apiSecret, RSA::PRIVATE_FORMAT_PKCS1); + if (!$loadResult) { + throw new AfterShipException('Load private key failed'); + } + $rsa->setHash("sha256"); + $rsa->setMGFHash('sha256'); + $rsa->setSignatureMode(RSA::SIGNATURE_PSS); + $key = $rsa->sign($data); + if (empty($key)) { + throw new AfterShipException('Sign with RSA_SIGN_PSS_2048_SHA256 failed'); + } + + return base64_encode($key); + } + + /** + * Calculate the hash of data with hmac-sha256 algorithm. + * https://www.aftership.com/docs/tracking/quickstart/authentication#2-aes + * @param $data The data to be encrypted. + * @return string the encrypted result with base64 encode + * @throws AfterShipException + */ + function hmacSha256Encrypt($data) { + return base64_encode(hash_hmac('sha256', $data, $this->apiSecret, true)); + } + +} diff --git a/src/Request.php b/src/Request.php index 2a6cb4c..75278d6 100644 --- a/src/Request.php +++ b/src/Request.php @@ -24,7 +24,18 @@ class Request implements Requestable * @var string */ protected $apiKey = ''; - + /** + * @var string + */ + private $apiSecret = ''; + /** + * @var string + */ + private $encryptionMethod = ''; + /** + * @var string + */ + private $encryptionPassword = ''; /** * Request constructor. * @@ -32,35 +43,60 @@ class Request implements Requestable */ function __construct($apiKey) { - $this->apiKey = $apiKey; + $apiSecret = ''; + $encryptionMethod = ''; + $encryptionPassword= ''; + $asApiKey=''; + + if (is_array($apiKey)) { + if (array_key_exists('api_secret', $apiKey)) { + $apiSecret = $apiKey['api_secret']; + } + if (array_key_exists('encryption_method', $apiKey)) { + $encryptionMethod = $apiKey['encryption_method']; + } + if (array_key_exists('encryption_password', $apiKey)) { + $encryptionPassword = $apiKey['encryption_password']; + } + if (array_key_exists('api_key', $apiKey)) { + $asApiKey = $apiKey['api_key']; + } + } else { + $asApiKey = $apiKey; + } + + $this->apiKey = $asApiKey; + $this->apiSecret = $apiSecret; + $this->encryptionMethod = $encryptionMethod; + $this->encryptionPassword = $encryptionPassword; } /** - * @param $url + * @param $path * @param $method * @param array $data * * @return mixed */ - public function send($method, $url, array $data = []) + public function send($method, $path, array $data = []) { + $url = self::API_URL . '/' . self::API_VERSION . '/' . $path; $methodUpper = strtoupper($method); - $headers = [ - 'aftership-api-key' => $this->apiKey, - 'content-type' => 'application/json' - ]; $parameters = [ - 'url' => self::API_URL . '/' . self::API_VERSION . '/' . $url, - 'headers' => array_map(function ($key, $value) { - return "$key: $value"; - }, array_keys($headers), $headers) + 'url' => $url, ]; + if ($methodUpper == 'GET' && $data > 0) { $parameters['url'] = $parameters['url'] . '?' . http_build_query($data); } else if ($methodUpper != 'GET') { $parameters['body'] = $this->safeJsonEncode($data); } + $headers = $this->getHeaders($method, $parameters['url'], $data); + $parameters['headers'] = array_map(function ($key, $value) { + return "$key: $value"; + }, array_keys($headers), $headers); + return $this->call($methodUpper, $parameters); } @@ -153,4 +189,113 @@ private function handleHttpStatusError($response, $curl, $code) curl_close($curl); throw new AfterShipException("$errType: $errCode - $errMessage", $errCode); } + + function getCanonicalizedHeaders($headers) + { + $filtered_headers = []; + + foreach ($headers as $key => $value) { + // Check if the header key starts with "as-" + if (strpos($key, 'as-') === 0) { + // Convert header key to lowercase and trim leading/trailing spaces + $key = strtolower(trim($key)); + + // Trim leading/trailing spaces from header value + $value = trim($value); + + // Concatenate header key and value + $filtered_headers[] = "{$key}:{$value}"; + } + } + + // Sort headers in ASCII code order + sort($filtered_headers, SORT_STRING); + + // Concatenate header pairs with new line character + $header_string = implode("\n", $filtered_headers); + + return $header_string; + } + + function getCanonicalizedResource($url) + { + $path = ""; + $query = ""; + $parse_url = parse_url($url); + if (array_key_exists('path', $parse_url)) { + $path = $parse_url['path']; + } + if (array_key_exists('query', $parse_url)) { + $query = $parse_url['query']; + } + if ($query === "") { + return $path; + } + + $params = explode("&", $query); + sort($params); + $queryStr = implode("&", $params); + $path .= "?" . $queryStr; + + return $path; + } + + function getSignString($method, $url, $data, $headers) + { + $contentMD5 = ""; + $contentType = ""; + if (!empty($data) && $method != "GET") { + $contentMD5 = strtoupper(md5($this->safeJsonEncode($data))); + $contentType = "application/json"; + } + + $canonicalizedHeaders = $this->getCanonicalizedHeaders($headers); + $canonicalizedResource = $this->getCanonicalizedResource($url); + return mb_convert_encoding($method."\n".$contentMD5."\n".$contentType."\n".$headers['date']."\n".$canonicalizedHeaders."\n".$canonicalizedResource, 'UTF-8'); + } + + private function getHeaders($method, $url, $data) + { + $isRSAEncryptionMethod = strcmp($this->encryptionMethod, Encryption::ENCRYPTION_RSA) == 0; + $isAESEncryptionMethod = strcmp($this->encryptionMethod, Encryption::ENCRYPTION_AES) == 0; + + // if not RSA or AES encryption, just return the legacy headers + if (!$isRSAEncryptionMethod && !$isAESEncryptionMethod) { + return [ + 'aftership-api-key' => $this->apiKey, + 'content-type' => 'application/json' + ]; + } + + $encryption = new Encryption($this->apiSecret, $this->encryptionMethod, $this->encryptionPassword); + // get the header `date` + date_default_timezone_set('UTC'); + $date = gmdate('D, d M Y H:i:s \G\M\T', time()); + $contentType = ""; + + // get the header `content-type` + if (!empty($data) && $method != "GET") { + $contentType = "application/json"; + } + + $headers = [ + 'as-api-key' => $this->apiKey, + 'date' => $date, + 'content-type' => $contentType, + ]; + $signString = $this->getSignString($method, $url, $data, $headers); + + if ($isRSAEncryptionMethod) { + $rsa = $encryption->rsaPSSSha256Encrypt($signString); + $headers['as-signature-rsa-sha256'] = $rsa; + + return $headers; + } + if ($isAESEncryptionMethod) { + $rsa = $encryption->hmacSha256Encrypt($signString); + $headers['as-signature-hmac-sha256'] = $rsa; + + return $headers; + } + } } diff --git a/src/Trackings.php b/src/Trackings.php index 3e24ae7..27b0c08 100644 --- a/src/Trackings.php +++ b/src/Trackings.php @@ -218,4 +218,42 @@ public function retrackById($trackingId) return $this->request->send('POST', 'trackings/' . $trackingId . '/retrack'); } + /** + * Mark a tracking as completed once by slug and tracking number. + * https://www.aftership.com/docs/api/4/trackings/post-trackings-slug-tracking_number-retrack + * @param string $slug The slug of tracking provider + * @param string $trackingNumber The tracking number which is provider by tracking provider + * @param array $additionalFields The tracking additional_fields required by some courier + * @return array Response body + * @throws AfterShipException + */ + public function markAsCompleted($slug, $trackingNumber, $additionalFields = [], array $params = []) + { + if (empty($slug)) { + throw new AfterShipException("Slug cannot be empty"); + } + + if (empty($trackingNumber)) { + throw new AfterShipException('Tracking number cannot be empty'); + } + + return $this->request->send('POST', 'trackings/' . $slug . '/' . $trackingNumber . '/mark-as-completed' . TrackingAdditionalFields::buildQuery($additionalFields, '?'), $params); + } + + /** + * Mark a tracking as completed once by tracking ID. + * https://www.aftership.com/docs/tracking/272f444a1eb42-mark-tracking-as-completed-by-id + * @param string $trackingId The tracking ID which is provided by AfterShip + * @return array Response body + * @throws \AfterShip\AfterShipException + */ + public function markAsCompletedById($trackingId, array $params = []) + { + if (empty($trackingId)) { + throw new AfterShipException('Tracking ID cannot be empty'); + } + + return $this->request->send('POST', 'trackings/' . $trackingId . '/mark-as-completed', $params); + } + } From 90415343fdcf230e25a0fb4ef84f7a89ca0389af Mon Sep 17 00:00:00 2001 From: Watson Zuo <hx.zuo@aftership.com> Date: Fri, 17 Mar 2023 14:00:23 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20update=20change=20lo?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d11953b..9af642b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.2.0] - 2023-03-17 +### Added +- Add API: POST `trackings/{slug}/{tracking_number}/mark-as-completed` +- Add API: POST `trackings/{id}/mark-as-completed` +- Add Request Header: `as-api-key` + ## [5.1.3] - 2022-07-20 ### Added - Add API: POST `estimated-delivery-date/predict-batch` From 768a8fe13d55bac1e602f062b5b6007053fd6e26 Mon Sep 17 00:00:00 2001 From: Watson Zuo <hx.zuo@aftership.com> Date: Mon, 20 Mar 2023 10:58:02 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 2 +- src/Encryption.php | 22 +++++----------------- src/Trackings.php | 2 +- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 50b1802..7f9b4ff 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ ], "require": { "php": ">=5.4.0", - "phpseclib/phpseclib": "~2.0" + "phpseclib/phpseclib": "~3.0" }, "require-dev": { "phpunit/phpunit": "~6.2", diff --git a/src/Encryption.php b/src/Encryption.php index a5122b4..81bb2b4 100644 --- a/src/Encryption.php +++ b/src/Encryption.php @@ -2,7 +2,7 @@ namespace AfterShip; -use phpseclib\Crypt\RSA; +use phpseclib3\Crypt\RSA; /** * Class Encryption @@ -48,22 +48,10 @@ function __construct($apiSecret, $encryptionMethod, $encryptionPassword) * @throws AfterShipException */ function rsaPSSSha256Encrypt($data) { - $rsa = new RSA(); - if (!empty($this->encryptionPassword )) { - $rsa->setPassword($this->encryptionPassword); - } - $loadResult = $rsa->loadKey($this->apiSecret, RSA::PRIVATE_FORMAT_PKCS1); - if (!$loadResult) { - throw new AfterShipException('Load private key failed'); - } - $rsa->setHash("sha256"); - $rsa->setMGFHash('sha256'); - $rsa->setSignatureMode(RSA::SIGNATURE_PSS); - $key = $rsa->sign($data); - if (empty($key)) { - throw new AfterShipException('Sign with RSA_SIGN_PSS_2048_SHA256 failed'); - } - + $key = RSA::loadPrivateKey($this->apiSecret, $this->encryptionPassword) + -> withHash("sha256") + -> withPadding(RSA::SIGNATURE_PSS) + -> sign($data); return base64_encode($key); } diff --git a/src/Trackings.php b/src/Trackings.php index 27b0c08..f7d82e0 100644 --- a/src/Trackings.php +++ b/src/Trackings.php @@ -220,7 +220,7 @@ public function retrackById($trackingId) /** * Mark a tracking as completed once by slug and tracking number. - * https://www.aftership.com/docs/api/4/trackings/post-trackings-slug-tracking_number-retrack + * https://www.aftership.com/docs/tracking/16935744036c1-mark-tracking-as-completed-legacy * @param string $slug The slug of tracking provider * @param string $trackingNumber The tracking number which is provider by tracking provider * @param array $additionalFields The tracking additional_fields required by some courier From a55dd13c15035a8911cd6391c91017819101a503 Mon Sep 17 00:00:00 2001 From: Watson Zuo <hx.zuo@aftership.com> Date: Mon, 20 Mar 2023 11:49:09 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20update=20changelogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ README.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af642b..7d5ba00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add API: POST `trackings/{slug}/{tracking_number}/mark-as-completed` - Add API: POST `trackings/{id}/mark-as-completed` - Add Request Header: `as-api-key` +- Add AES signature +- Add RSA signature ## [5.1.3] - 2022-07-20 ### Added diff --git a/README.md b/README.md index 48ba99e..b7b0783 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,22 @@ sudo apt-get install php5-curl ``` and restart the web server and PHP process. +Use signature headers +```php +require 'vendor/autoload.php'; + +$api_key = 'AFTERSHIP API KEY'; +$api_secret = 'Your api secret'; // if the encryption_method = RSA, the api_secret is PEM private key +$encryption_method = 'AES or RSA'; +$encryption_password = 'PEM pass phrase'; + +$key = ['api_key' => $api_key, 'api_secret' => $api_secret, 'encryption_method' => $encryption_method, 'encryption_password' => $encryption_password] + +$couriers = new AfterShip\Couriers($key); +$trackings = new AfterShip\Trackings($key); +$last_check_point = new AfterShip\LastCheckPoint($key); +``` + ## Testing 1. Execute the file: From 7d0a5de87edceb7ae73559462eef6a013d750ab9 Mon Sep 17 00:00:00 2001 From: Watson Zuo <hx.zuo@aftership.com> Date: Mon, 20 Mar 2023 13:54:11 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update --- src/Request.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Request.php b/src/Request.php index 75278d6..8e90f4f 100644 --- a/src/Request.php +++ b/src/Request.php @@ -23,7 +23,7 @@ class Request implements Requestable /** * @var string */ - protected $apiKey = ''; + protected $apiKey; /** * @var string */ @@ -46,7 +46,7 @@ function __construct($apiKey) $apiSecret = ''; $encryptionMethod = ''; $encryptionPassword= ''; - $asApiKey=''; + $asApiKey=$apiKey; if (is_array($apiKey)) { if (array_key_exists('api_secret', $apiKey)) { @@ -61,8 +61,6 @@ function __construct($apiKey) if (array_key_exists('api_key', $apiKey)) { $asApiKey = $apiKey['api_key']; } - } else { - $asApiKey = $apiKey; } $this->apiKey = $asApiKey; From 78072242c05cdc804087e1080b5bc77029d262ba Mon Sep 17 00:00:00 2001 From: Watson Zuo <hx.zuo@aftership.com> Date: Mon, 20 Mar 2023 18:20:43 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20composer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update php version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7f9b4ff..274e385 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": ">=5.4.0", + "php": ">=5.6.1", "phpseclib/phpseclib": "~3.0" }, "require-dev": {