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": {