├── .devcontainer └── devcontainer.json ├── LICENSE ├── README.md ├── composer.json └── src ├── Certificate └── AppleWWDRCA.pem ├── FinanceOrder.php ├── PKPass.php └── PKPassException.php /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/shyim/devcontainers-features/php:0": {} 5 | } 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Includable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP library to create passes for iOS Wallet 2 | 3 | [![Packagist Version](https://img.shields.io/packagist/v/pkpass/pkpass)](https://packagist.org/packages/pkpass/pkpass) 4 | [![Packagist Downloads](https://img.shields.io/packagist/dt/pkpass/pkpass)](https://packagist.org/packages/pkpass/pkpass) 5 | [![Packagist License](https://img.shields.io/packagist/l/pkpass/pkpass)](LICENSE) 6 | 7 | This class provides the functionality to create passes for Wallet in Apple's iOS. It creates, 8 | signs and packages the pass as a `.pkpass` file according to Apple's documentation. 9 | 10 | ## Requirements 11 | 12 | - PHP 7.0 or higher (may also work with older versions) 13 | - PHP [ZIP extension](http://php.net/manual/en/book.zip.php) (often installed by default) 14 | - Access to filesystem to write temporary cache files 15 | 16 | ## Installation 17 | 18 | Simply run the following command in your project's root directory to install via [Composer](https://getcomposer.org/): 19 | 20 | ``` 21 | composer require pkpass/pkpass 22 | ``` 23 | 24 | Or add to your composer.json: `"pkpass/pkpass": "^2.0.0"` 25 | 26 | ## Usage 27 | 28 | Please take a look at the [examples/example.php](examples/example.php) file for example usage. For more info on the JSON for the pass and how to 29 | style it, take a look at the [docs at developers.apple.com](https://developer.apple.com/library/ios/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/Introduction.html). 30 | 31 | ### Included demos 32 | 33 | - 📦 [Simple example](examples/example.php) 34 | - ✈️ [Flight ticket example](examples/full_sample/) 35 | - ☕️ [Starbucks card example](examples/starbucks_sample/) 36 | 37 | ### Functions to add files 38 | 39 | - `addFile` : add a file without locale like `icon.png` 40 | - `addRemoteFile` : add a file from a url without locale like `https://xyz.io/icon.png` 41 | - `addLocaleFile` : add a localized file like `strip.png` 42 | - `addLocaleRemoteFile` : add a localized file from a url like `https://xyz.io/strip.png` 43 | 44 | ## Requesting the Pass Certificate 45 | 46 | 1. Go to the [iOS Provisioning portal](https://developer.apple.com/account/ios/identifier/passTypeId). 47 | 2. Create a new Pass Type ID, and write down the Pass ID you choose, you'll need it later. 48 | 3. Click the edit button under your newly created Pass Type ID and generate a certificate according to the instructions 49 | shown on the page. Make sure _not_ to choose a name for the Certificate but keep it empty instead. 50 | 4. Download the .cer file and drag it into Keychain Access. 51 | 5. Choose to filter by **Certificates** in the top filter bar. 52 | 6. Find the certificate you just imported and click the triangle on the left to reveal the private key. 53 | 7. Select both the certificate and the private key it, then right-click the certificate in Keychain Access and 54 | choose `Export 2 items…`. 55 | 8. Choose a password and export the file to a folder. 56 | 57 | ![Exporting P12 file](docs/guide-export.gif) 58 | 59 | ### Getting the example.php sample to work 60 | 61 | 1. Request the Pass certificate (`.p12`) as described above and upload it to your server. 62 | 2. Set the correct path and password on [line 22](examples/example.php#L22). 63 | 3. Change the `passTypeIdentifier` and `teamIndentifier` to the correct values on lines [29](examples/example.php#L29) and [31](examples/example.php#L31) (`teamIndentifier` can be found on the [Developer Portal](https://developer.apple.com/account/#/membership)). 64 | 65 | After completing these steps, you should be ready to go. Upload all the files to your server and navigate to the address 66 | of the examples/example.php file on your iPhone. 67 | 68 | ## Debugging 69 | 70 | ### Using the Console app 71 | 72 | If you aren't able to open your pass on an iPhone, plug the iPhone into a Mac and open the 'Console' application. On the left, you can select your iPhone. You will then be able to inspect any errors that occur while adding the pass: 73 | 74 | ![Console with Passkit error](docs/console.png) 75 | 76 | - `Trust evaluate failure: [leaf TemporalValidity]`: If you see this error, your pass was signed with an outdated certificate. 77 | - `Trust evaluate failure: [leaf LeafMarkerOid]`: You did not leave the name of the certificate empty while creating it in the developer portal. 78 | 79 | ### OpenSSL errors 80 | 81 | When you get the error 'Could not read certificate file', this might be related to using an OpenSSL version that has deprecated some older hashes - [more info here](https://schof.link/2Et6z3m). 82 | 83 | There may be no need to configure OpenSSL to use legacy algorithms. It's easier and more portable just to convert the encrypted certificates file. The steps below use a .p12 file but it should work to swap these commands for a .pfx file. 84 | 85 | Instructions: 86 | 87 | 1. `openssl pkcs12 -legacy -in key.p12 -nodes -out key_decrypted.tmp` (replace key.p12 with your .p12 file name). 88 | 2. `openssl pkcs12 -in key_decrypted.tmp -export -out key_new.p12 -certpbe AES-256-CBC -keypbe AES-256-CBC -iter 2048` (use the newly generated key_new.p12 file in your pass generation below) 89 | 90 | The `key_new.p12` file should now be compatible with OpenSSL v3+. 91 | 92 | ## Changelog 93 | 94 | **Version 2.3.2 - September 2024** 95 | 96 | - Fix order mime type, add better error reporting. 97 | 98 | **Version 2.3.1 - March 2024** 99 | 100 | - Chore: add gitattributes. 101 | 102 | **Version 2.3.0 - February 2024** 103 | 104 | - Add support for Wallet Orders. 105 | 106 | **Version 2.2.0 - December 2023** 107 | 108 | - Update default WWDR certificate to G4. 109 | 110 | **Version 2.1.0 - April 2023** 111 | 112 | - Add alternative method for extracting P12 contents to circumvent issues in recent versions of OpenSSL. 113 | 114 | **Version 2.0.2 - October 2022** 115 | 116 | - Switch to `ZipArchive::OVERWRITE` method of opening ZIP due to PHP 8 deprecation ([#120](https://github.com/includable/php-pkpass/pull/120)). 117 | 118 | **Version 2.0.1 - October 2022** 119 | 120 | - Update WWDR certificate to v6 ([#118](https://github.com/includable/php-pkpass/issues/118)). 121 | 122 | **Version 2.0.0 - September 2022** 123 | 124 | - Changed signature of constructor to take out third `$json` parameter. 125 | - Remove deprecated `setJSON()` method. 126 | - Removed `checkError()` and `getError()` methods in favor of exceptions. 127 | 128 | ## Support & documentation 129 | 130 | Please read the instructions above and consult the [Wallet Documentation](https://developer.apple.com/wallet/) before 131 | submitting tickets or requesting support. It might also be worth 132 | to [check Stackoverflow](http://stackoverflow.com/search?q=%22PHP-PKPass%22), which contains quite a few questions about 133 | this library. 134 | 135 |

136 | 137 | --- 138 | 139 |
140 | 141 | Get professional support for this package → 142 | 143 |
144 | 145 | Custom consulting sessions available for implementation support and feature development. 146 | 147 |
148 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pkpass/pkpass", 3 | "license": "MIT", 4 | "description": "PHP PKPass class for iOS Wallet", 5 | "keywords": [ 6 | "PHP", 7 | "apple", 8 | "ios", 9 | "iphone", 10 | "ipad", 11 | "passbook", 12 | "wallet" 13 | ], 14 | "homepage": "https://github.com/includable/php-pkpass", 15 | "type": "library", 16 | "authors": [ 17 | { 18 | "name": "Thomas Schoffelen", 19 | "homepage": "https://schof.co/", 20 | "email": "thomas@includable.com" 21 | } 22 | ], 23 | "autoload": { 24 | "psr-4": { 25 | "PKPass\\": "src" 26 | } 27 | }, 28 | "require": { 29 | "php": ">=7.0", 30 | "ext-zip": "*", 31 | "ext-json": "*", 32 | "ext-openssl": "*" 33 | }, 34 | "require-dev": { 35 | "phpunit/phpunit": "^9.6" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Certificate/AppleWWDRCA.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEVTCCAz2gAwIBAgIUE9x3lVJx5T3GMujM/+Uh88zFztIwDQYJKoZIhvcNAQEL 3 | BQAwYjELMAkGA1UEBhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsT 4 | HUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBS 5 | b290IENBMB4XDTIwMTIxNjE5MzYwNFoXDTMwMTIxMDAwMDAwMFowdTFEMEIGA1UE 6 | Aww7QXBwbGUgV29ybGR3aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNh 7 | dGlvbiBBdXRob3JpdHkxCzAJBgNVBAsMAkc0MRMwEQYDVQQKDApBcHBsZSBJbmMu 8 | MQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANAf 9 | eKp6JzKwRl/nF3bYoJ0OKY6tPTKlxGs3yeRBkWq3eXFdDDQEYHX3rkOPR8SGHgjo 10 | v9Y5Ui8eZ/xx8YJtPH4GUnadLLzVQ+mxtLxAOnhRXVGhJeG+bJGdayFZGEHVD41t 11 | QSo5SiHgkJ9OE0/QjJoyuNdqkh4laqQyziIZhQVg3AJK8lrrd3kCfcCXVGySjnYB 12 | 5kaP5eYq+6KwrRitbTOFOCOL6oqW7Z+uZk+jDEAnbZXQYojZQykn/e2kv1MukBVl 13 | PNkuYmQzHWxq3Y4hqqRfFcYw7V/mjDaSlLfcOQIA+2SM1AyB8j/VNJeHdSbCb64D 14 | YyEMe9QbsWLFApy9/a8CAwEAAaOB7zCB7DASBgNVHRMBAf8ECDAGAQH/AgEAMB8G 15 | A1UdIwQYMBaAFCvQaUeUdgn+9GuNLkCm90dNfwheMEQGCCsGAQUFBwEBBDgwNjA0 16 | BggrBgEFBQcwAYYoaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBsZXJv 17 | b3RjYTAuBgNVHR8EJzAlMCOgIaAfhh1odHRwOi8vY3JsLmFwcGxlLmNvbS9yb290 18 | LmNybDAdBgNVHQ4EFgQUW9n6HeeaGgujmXYiUIY+kchbd6gwDgYDVR0PAQH/BAQD 19 | AgEGMBAGCiqGSIb3Y2QGAgEEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQA/Vj2e5bbD 20 | eeZFIGi9v3OLLBKeAuOugCKMBB7DUshwgKj7zqew1UJEggOCTwb8O0kU+9h0UoWv 21 | p50h5wESA5/NQFjQAde/MoMrU1goPO6cn1R2PWQnxn6NHThNLa6B5rmluJyJlPef 22 | x4elUWY0GzlxOSTjh2fvpbFoe4zuPfeutnvi0v/fYcZqdUmVIkSoBPyUuAsuORFJ 23 | EtHlgepZAE9bPFo22noicwkJac3AfOriJP6YRLj477JxPxpd1F1+M02cHSS+APCQ 24 | A1iZQT0xWmJArzmoUUOSqwSonMJNsUvSq3xKX+udO7xPiEAGE/+QF4oIRynoYpgp 25 | pU8RBWk6z/Kf 26 | -----END CERTIFICATE----- 27 | -------------------------------------------------------------------------------- /src/FinanceOrder.php: -------------------------------------------------------------------------------- 1 | json); 40 | 41 | // Creates SHA hashes for string files in each project. 42 | foreach ($this->locales as $language => $strings) { 43 | $sha[$language . '.lproj/' . self::FILE_TYPE . '.strings'] = hash(self::HASH_ALGO, $strings); 44 | } 45 | 46 | foreach ($this->files as $name => $path) { 47 | $sha[$name] = hash(self::HASH_ALGO, file_get_contents($path)); 48 | } 49 | 50 | foreach ($this->remote_file_urls as $name => $url) { 51 | $sha[$name] = hash(self::HASH_ALGO, file_get_contents($url)); 52 | } 53 | 54 | foreach ($this->files_content as $name => $content) { 55 | $sha[$name] = hash(self::HASH_ALGO, $content); 56 | } 57 | 58 | return json_encode((object)$sha); 59 | } 60 | 61 | /** 62 | * Creates .pkpass zip archive. 63 | * 64 | * @param string $manifest 65 | * @param string $signature 66 | * @return string 67 | * @throws PKPassException 68 | */ 69 | protected function createZip($manifest, $signature) 70 | { 71 | // Package file in Zip (as .order) 72 | $zip = new ZipArchive(); 73 | $filename = tempnam($this->tempPath, self::FILE_TYPE); 74 | if (!$zip->open($filename, ZipArchive::OVERWRITE)) { 75 | throw new PKPassException('Could not open ' . basename($filename) . ' with ZipArchive extension.'); 76 | } 77 | $zip->addFromString('signature', $signature); 78 | $zip->addFromString('manifest.json', $manifest); 79 | $zip->addFromString(self::PAYLOAD_FILE, $this->json); 80 | 81 | // Add translation dictionary 82 | foreach ($this->locales as $language => $strings) { 83 | if (!$zip->addEmptyDir($language . '.lproj')) { 84 | throw new PKPassException('Could not create ' . $language . '.lproj folder in zip archive.'); 85 | } 86 | $zip->addFromString($language . '.lproj/' . self::FILE_TYPE . '.strings', $strings); 87 | } 88 | 89 | foreach ($this->files as $name => $path) { 90 | $zip->addFile($path, $name); 91 | } 92 | 93 | foreach ($this->remote_file_urls as $name => $url) { 94 | $download_file = file_get_contents($url); 95 | $zip->addFromString($name, $download_file); 96 | } 97 | 98 | foreach ($this->files_content as $name => $content) { 99 | $zip->addFromString($name, $content); 100 | } 101 | 102 | $zip->close(); 103 | 104 | if (!file_exists($filename) || filesize($filename) < 1) { 105 | @unlink($filename); 106 | throw new PKPassException('Error while creating order.order. Check your ZIP extension.'); 107 | } 108 | 109 | $content = file_get_contents($filename); 110 | unlink($filename); 111 | 112 | return $content; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/PKPass.php: -------------------------------------------------------------------------------- 1 | tempPath = sys_get_temp_dir(); 98 | $this->wwdrCertPath = __DIR__ . '/Certificate/AppleWWDRCA.pem'; 99 | 100 | if ($certificatePath) { 101 | $this->setCertificatePath($certificatePath); 102 | } 103 | if ($certificatePassword) { 104 | $this->setCertificatePassword($certificatePassword); 105 | } 106 | } 107 | 108 | /** 109 | * Sets the path to a certificate 110 | * Parameter: string, path to certificate 111 | * Return: boolean, always true. 112 | * 113 | * @param string $path 114 | * 115 | * @return bool 116 | */ 117 | public function setCertificatePath($path) 118 | { 119 | $this->certPath = $path; 120 | 121 | return true; 122 | } 123 | 124 | /** 125 | * Sets the certificate's password 126 | * Parameter: string, password to the certificate 127 | * Return: boolean, always true. 128 | * 129 | * @param string $password 130 | * 131 | * @return bool 132 | */ 133 | public function setCertificatePassword($password) 134 | { 135 | $this->certPass = $password; 136 | 137 | return true; 138 | } 139 | 140 | /** 141 | * Sets the path to the WWDR Intermediate certificate 142 | * Parameter: string, path to certificate 143 | * Return: boolean, always true. 144 | * 145 | * @param string $path 146 | * 147 | * @return bool 148 | */ 149 | public function setWwdrCertificatePath($path) 150 | { 151 | $this->wwdrCertPath = $path; 152 | 153 | return true; 154 | } 155 | 156 | /** 157 | * Set the path to the temporary directory. 158 | * 159 | * @param string $path Path to temporary directory 160 | */ 161 | public function setTempPath($path) 162 | { 163 | $this->tempPath = $path; 164 | } 165 | 166 | /** 167 | * Set pass data. 168 | * 169 | * @param string|array|object $data 170 | * @throws PKPassException 171 | */ 172 | public function setData($data) 173 | { 174 | // Array is passed as input 175 | if (is_array($data) || is_object($data)) { 176 | $this->json = json_encode($data); 177 | return; 178 | } 179 | 180 | // JSON string is passed as input 181 | if (json_decode($data) !== false) { 182 | $this->json = $data; 183 | return; 184 | } 185 | 186 | throw new PKPassException('Invalid data passed to setData: this is not a JSON string.'); 187 | } 188 | 189 | /** 190 | * Add dictionary of strings for translation. 191 | * 192 | * @param string $language language project need to be added 193 | * @param array $strings a key value pair of translation strings (default is equal to []) 194 | * @throws PKPassException 195 | */ 196 | public function addLocaleStrings($language, $strings = []) 197 | { 198 | if (!is_array($strings) || empty($strings)) { 199 | throw new PKPassException('Translation strings empty or not an array.'); 200 | } 201 | 202 | $dictionary = ""; 203 | foreach ($strings as $key => $value) { 204 | $dictionary .= '"' . $this->escapeLocaleString($key) . '" = "' . $this->escapeLocaleString($value) . '";' . PHP_EOL; 205 | } 206 | $this->locales[$language] = $dictionary; 207 | } 208 | 209 | /** 210 | * Add a localized file to the file array. 211 | * 212 | * @param string $language language for which file to be added 213 | * @param string $path Path to file 214 | * @param string $name Filename to use in pass archive (default is equal to $path) 215 | * @throws PKPassException 216 | */ 217 | public function addLocaleFile($language, $path, $name = null) 218 | { 219 | if (!file_exists($path)) { 220 | throw new PKPassException(sprintf('File %s does not exist.', $path)); 221 | } 222 | 223 | $name = $name ?: basename($path); 224 | $this->files[$language . '.lproj/' . $name] = $path; 225 | } 226 | 227 | /** 228 | * Add a file to the file array. 229 | * 230 | * @param string $path Path to file 231 | * @param string $name Filename to use in pass archive (default is equal to $path) 232 | * @throws PKPassException 233 | */ 234 | public function addFile($path, $name = null) 235 | { 236 | if (!file_exists($path)) { 237 | throw new PKPassException(sprintf('File %s does not exist.', $path)); 238 | } 239 | 240 | $name = $name ?: basename($path); 241 | $this->files[$name] = $path; 242 | 243 | return false; 244 | } 245 | 246 | /** 247 | * Add a file from a url to the remote file urls array. 248 | * 249 | * @param string $url URL to file 250 | * @param string $name Filename to use in pass archive (default is equal to $url) 251 | */ 252 | public function addRemoteFile($url, $name = null) 253 | { 254 | $name = $name ?: basename($url); 255 | $this->remote_file_urls[$name] = $url; 256 | } 257 | 258 | /** 259 | * Add a locale file from a url to the remote file urls array. 260 | * 261 | * @param string $language language for which file to be added 262 | * @param string $content Content of file 263 | * @param string $name Filename to use in pass archive (default is equal to $url) 264 | */ 265 | public function addLocaleRemoteFile($language, $url, $name = null) 266 | { 267 | $name = $name ?: basename($url); 268 | $this->remote_file_urls[$language . '.lproj/' . $name] = $url; 269 | } 270 | 271 | /** 272 | * Add a file from a string to the string files array. 273 | * 274 | * @param string $content Content of file 275 | * @param string $name Filename to use in pass archive (default is equal to $url) 276 | */ 277 | public function addFileContent($content, $name) 278 | { 279 | $this->files_content[$name] = $content; 280 | } 281 | 282 | /** 283 | * Add a locale file from a string to the string files array. 284 | * 285 | * @param string $language language for which file to be added 286 | * @param string $content Content of file 287 | * @param string $name Filename to use in pass archive (default is equal to $url) 288 | */ 289 | public function addLocaleFileContent($language, $content, $name) 290 | { 291 | $this->files_content[$language . '.lproj/' . $name] = $content; 292 | } 293 | 294 | /** 295 | * Create the actual .pkpass file. 296 | * 297 | * @param bool $output Whether to output it directly or return the pass contents as a string. 298 | * 299 | * @return string 300 | * @throws PKPassException 301 | */ 302 | public function create($output = false) 303 | { 304 | // Prepare payload 305 | $manifest = $this->createManifest(); 306 | $signature = $this->createSignature($manifest); 307 | 308 | // Build ZIP file 309 | $zip = $this->createZip($manifest, $signature); 310 | 311 | // Return pass 312 | if (!$output) { 313 | return $zip; 314 | } 315 | 316 | // Output pass 317 | header('Content-Description: File Transfer'); 318 | header('Content-Type: ' . static::MIME_TYPE); 319 | header('Content-Disposition: attachment; filename="' . $this->getName() . '"'); 320 | header('Content-Transfer-Encoding: binary'); 321 | header('Connection: Keep-Alive'); 322 | header('Expires: 0'); 323 | header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); 324 | header('Last-Modified: ' . gmdate('D, d M Y H:i:s T')); 325 | header('Pragma: public'); 326 | echo $zip; 327 | 328 | return ''; 329 | } 330 | 331 | /** 332 | * Get filename. 333 | * 334 | * @return string 335 | */ 336 | public function getName() 337 | { 338 | $name = $this->name ?: static::FILE_TYPE; 339 | if (!strstr($name, '.')) { 340 | $name .= '.' . static::FILE_EXT; 341 | } 342 | 343 | return $name; 344 | } 345 | 346 | /** 347 | * Set filename. 348 | * 349 | * @param string $name 350 | */ 351 | public function setName($name) 352 | { 353 | $this->name = $name; 354 | } 355 | 356 | /** 357 | * Sub-function of create() 358 | * This function creates the hashes for the files and adds them into a json string. 359 | * 360 | * @throws PKPassException 361 | */ 362 | protected function createManifest() 363 | { 364 | // Creates SHA hashes for all files in package 365 | $sha = []; 366 | $sha['pass.json'] = sha1($this->json); 367 | 368 | // Creates SHA hashes for string files in each project. 369 | foreach ($this->locales as $language => $strings) { 370 | $sha[$language . '.lproj/pass.strings'] = sha1($strings); 371 | } 372 | 373 | $has_icon = false; 374 | foreach ($this->files as $name => $path) { 375 | if (strtolower($name) == 'icon.png') { 376 | $has_icon = true; 377 | } 378 | $sha[$name] = sha1(file_get_contents($path)); 379 | } 380 | 381 | foreach ($this->remote_file_urls as $name => $url) { 382 | if (strtolower($name) == 'icon.png') { 383 | $has_icon = true; 384 | } 385 | $sha[$name] = sha1(file_get_contents($url)); 386 | } 387 | 388 | foreach ($this->files_content as $name => $content) { 389 | if (strtolower($name) == 'icon.png') { 390 | $has_icon = true; 391 | } 392 | $sha[$name] = sha1($content); 393 | } 394 | 395 | if (!$has_icon) { 396 | throw new PKPassException('Missing required icon.png file.'); 397 | } 398 | 399 | return json_encode((object)$sha); 400 | } 401 | 402 | /** 403 | * Converts PKCS7 PEM to PKCS7 DER 404 | * Parameter: string, holding PKCS7 PEM, binary, detached 405 | * Return: string, PKCS7 DER. 406 | * 407 | * @param string $signature 408 | * 409 | * @return string 410 | */ 411 | protected function convertPEMtoDER($signature) 412 | { 413 | $begin = 'filename="smime.p7s"'; 414 | $end = '------'; 415 | $signature = substr($signature, strpos($signature, $begin) + strlen($begin)); 416 | 417 | $signature = substr($signature, 0, strpos($signature, $end)); 418 | $signature = trim($signature); 419 | 420 | return base64_decode($signature); 421 | } 422 | 423 | /** 424 | * Read a PKCS12 certificate string and turn it into an array. 425 | * 426 | * @return array 427 | * @throws PKPassException 428 | */ 429 | protected function readP12() 430 | { 431 | // Use the built-in reader first 432 | if (!$pkcs12 = file_get_contents($this->certPath)) { 433 | throw new PKPassException('Could not read the certificate.'); 434 | } 435 | $certs = []; 436 | if (openssl_pkcs12_read($pkcs12, $certs, $this->certPass)) { 437 | return $certs; 438 | } 439 | 440 | // That failed, let's check why 441 | $error = ''; 442 | while ($text = openssl_error_string()) { 443 | $error .= $text; 444 | } 445 | 446 | // General error 447 | if (!strstr($error, 'digital envelope routines::unsupported')) { 448 | throw new PKPassException( 449 | 'Invalid certificate file. Make sure you have a ' . 450 | 'P12 certificate that also contains a private key, and you ' . 451 | 'have specified the correct password!' . PHP_EOL . PHP_EOL . 452 | 'OpenSSL error: ' . $error 453 | ); 454 | } 455 | 456 | // Try an alternative route using shell_exec 457 | try { 458 | $value = @shell_exec( 459 | "openssl pkcs12 -in " . escapeshellarg($this->certPath) . 460 | " -passin " . escapeshellarg("pass:" . $this->certPass) . 461 | " -passout " . escapeshellarg("pass:" . $this->certPass) . 462 | " -legacy" 463 | ); 464 | if ($value) { 465 | $cert = substr($value, strpos($value, '-----BEGIN CERTIFICATE-----')); 466 | $cert = substr($cert, 0, strpos($cert, '-----END CERTIFICATE-----') + 25); 467 | $key = substr($value, strpos($value, '-----BEGIN ENCRYPTED PRIVATE KEY-----')); 468 | $key = substr($key, 0, strpos($key, '-----END ENCRYPTED PRIVATE KEY-----') + 35); 469 | if (strlen($cert) > 0 && strlen($key) > 0) { 470 | $certs['cert'] = $cert; 471 | $certs['pkey'] = $key; 472 | return $certs; 473 | } 474 | } 475 | } catch (\Throwable $e) { 476 | // no need to do anything 477 | } 478 | 479 | throw new PKPassException( 480 | 'Could not read certificate file. This might be related ' . 481 | 'to using an OpenSSL version that has deprecated some older ' . 482 | 'hashes. More info here: https://schof.link/2Et6z3m ' . PHP_EOL . PHP_EOL . 483 | 'OpenSSL error: ' . $error 484 | ); 485 | } 486 | 487 | /** 488 | * Creates a signature and saves it. 489 | * 490 | * @param string $manifest 491 | * @throws PKPassException 492 | */ 493 | protected function createSignature($manifest) 494 | { 495 | $manifest_path = tempnam($this->tempPath, 'pkpass'); 496 | $signature_path = tempnam($this->tempPath, 'pkpass'); 497 | file_put_contents($manifest_path, $manifest); 498 | 499 | $certs = $this->readP12(); 500 | $certdata = openssl_x509_read($certs['cert']); 501 | $privkey = openssl_pkey_get_private($certs['pkey'], $this->certPass); 502 | 503 | $openssl_args = [ 504 | $manifest_path, 505 | $signature_path, 506 | $certdata, 507 | $privkey, 508 | [], 509 | PKCS7_BINARY | PKCS7_DETACHED 510 | ]; 511 | 512 | if (!empty($this->wwdrCertPath)) { 513 | if (!file_exists($this->wwdrCertPath)) { 514 | throw new PKPassException('WWDR Intermediate Certificate does not exist.'); 515 | } 516 | 517 | $openssl_args[] = $this->wwdrCertPath; 518 | } 519 | 520 | call_user_func_array('openssl_pkcs7_sign', $openssl_args); 521 | 522 | $signature = file_get_contents($signature_path); 523 | unlink($manifest_path); 524 | unlink($signature_path); 525 | 526 | return $this->convertPEMtoDER($signature); 527 | } 528 | 529 | /** 530 | * Creates .pkpass zip archive. 531 | * 532 | * @param string $manifest 533 | * @param string $signature 534 | * @return string 535 | * @throws PKPassException 536 | */ 537 | protected function createZip($manifest, $signature) 538 | { 539 | // Package file in Zip (as .pkpass) 540 | $zip = new ZipArchive(); 541 | $filename = tempnam($this->tempPath, 'pkpass'); 542 | if (!$zip->open($filename, ZipArchive::OVERWRITE)) { 543 | throw new PKPassException('Could not open ' . basename($filename) . ' with ZipArchive extension.'); 544 | } 545 | $zip->addFromString('signature', $signature); 546 | $zip->addFromString('manifest.json', $manifest); 547 | $zip->addFromString('pass.json', $this->json); 548 | 549 | // Add translation dictionary 550 | foreach ($this->locales as $language => $strings) { 551 | if (!$zip->addEmptyDir($language . '.lproj')) { 552 | throw new PKPassException('Could not create ' . $language . '.lproj folder in zip archive.'); 553 | } 554 | $zip->addFromString($language . '.lproj/pass.strings', $strings); 555 | } 556 | 557 | foreach ($this->files as $name => $path) { 558 | $zip->addFile($path, $name); 559 | } 560 | 561 | foreach ($this->remote_file_urls as $name => $url) { 562 | $download_file = file_get_contents($url); 563 | $zip->addFromString($name, $download_file); 564 | } 565 | 566 | foreach ($this->files_content as $name => $content) { 567 | $zip->addFromString($name, $content); 568 | } 569 | 570 | $zip->close(); 571 | 572 | if (!file_exists($filename) || filesize($filename) < 1) { 573 | @unlink($filename); 574 | throw new PKPassException('Error while creating pass.pkpass. Check your ZIP extension.'); 575 | } 576 | 577 | $content = file_get_contents($filename); 578 | unlink($filename); 579 | 580 | return $content; 581 | } 582 | 583 | protected static $escapeChars = [ 584 | "\n" => "\\n", 585 | "\r" => "\\r", 586 | "\"" => "\\\"", 587 | "\\" => "\\\\" 588 | ]; 589 | 590 | /** 591 | * Escapes strings for use in locale files 592 | * @param string $string 593 | * @return string 594 | */ 595 | protected function escapeLocaleString($string) 596 | { 597 | return strtr($string, self::$escapeChars); 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /src/PKPassException.php: -------------------------------------------------------------------------------- 1 |