diff --git a/README.md b/README.md index 5f4c3b0..bb94891 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ This repo contains various packages to deal with PkPass files. -| Package | Description | Pub.dev | Version | -|----------------------------------|------------------------------------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------| -| [`app`](app) | App which makes use of the various libraries. Android only. | | | -| [`apple_passkit`](apple_passkit) | Package to interface with the native functionality on iOS | https://pub.dev/packages/apple_passkit | [![pub package](https://img.shields.io/pub/v/apple_passkit.svg)](https://pub.dev/packages/apple_passkit) | -| [`passkit`](passkit) | Pure Dart package to read PkPass files. Can be used on server side too | https://pub.dev/packages/passkit | [![pub package](https://img.shields.io/pub/v/passkit.svg)](https://pub.dev/packages/passkit) | -| [`passkit_ui`](passkit_ui) | Pure Flutter package to show PkPass files. Makes use of `passkit` | https://pub.dev/packages/passkit_ui | [![pub package](https://img.shields.io/pub/v/passkit_ui.svg)](https://pub.dev/packages/passkit_ui) | +| Package | Description | Version | Likes | Popularity | Points | +| ---------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| [`app`](app) | App which makes use of the various libraries. Android only. | | | | | +| [`apple_passkit`](apple_passkit) | Package to interface with the native functionality on iOS | [![pub package](https://img.shields.io/pub/v/apple_passkit.svg)](https://pub.dev/packages/apple_passkit) | [![likes](https://img.shields.io/pub/likes/apple_passkit)](https://pub.dev/packages/apple_passkit/score) | [![popularity](https://img.shields.io/pub/popularity/apple_passkit)](https://pub.dev/packages/apple_passkit/score) | [![pub points](https://img.shields.io/pub/points/apple_passkit)](https://pub.dev/packages/apple_passkit/score) | +| [`passkit_server`](passkit_server) | Dart package to add PassKit enpoints to a shelf server. | [![pub package](https://img.shields.io/pub/v/passkit_server.svg)](https://pub.dev/packages/passkit_server) | [![likes](https://img.shields.io/pub/likes/passkit_server)](https://pub.dev/packages/passkit_server/score) | [![popularity](https://img.shields.io/pub/popularity/passkit_server)](https://pub.dev/packages/passkit_server/score) | [![pub points](https://img.shields.io/pub/points/passkit_server)](https://pub.dev/packages/passkit_server/score) | +| [`passkit_ui`](passkit_ui) | Pure Flutter package to show PkPass files. Makes use of `passkit` | [![pub package](https://img.shields.io/pub/v/passkit_ui.svg)](https://pub.dev/packages/passkit_ui) | [![likes](https://img.shields.io/pub/likes/passkit_ui)](https://pub.dev/packages/passkit_ui/score) | [![popularity](https://img.shields.io/pub/popularity/passkit_ui)](https://pub.dev/packages/passkit_ui/score) | [![pub points](https://img.shields.io/pub/points/passkit_ui)](https://pub.dev/packages/passkit_ui/score) | +| [`passkit`](passkit) | Pure Dart package to read PkPass files. Can be used on server side too | [![pub package](https://img.shields.io/pub/v/passkit.svg)](https://pub.dev/packages/passkit) | [![likes](https://img.shields.io/pub/likes/passkit)](https://pub.dev/packages/passkit/score) | [![popularity](https://img.shields.io/pub/popularity/passkit)](https://pub.dev/packages/passkit/score) | [![pub points](https://img.shields.io/pub/points/passkit)](https://pub.dev/packages/passkit/score) | diff --git a/app/lib/import_pass/import_page.dart b/app/lib/import_pass/import_page.dart index 4e385d1..b996bd8 100644 --- a/app/lib/import_pass/import_page.dart +++ b/app/lib/import_pass/import_page.dart @@ -22,7 +22,7 @@ class PkPassImportSource { final String? contentResolverPath; final String? filePath; - final List? bytes; + final Uint8List? bytes; Future getPass() async { if (contentResolverPath != null) { @@ -101,7 +101,7 @@ class _ImportPassPageState extends State { await db.passEntryDao.insertPassEntry( PassEntry( id: pass!.pass.serialNumber, - pass: Uint8List.fromList(pass!.sourceData), + pass: pass!.sourceData!, description: pass!.pass.description, ), ); diff --git a/app/lib/pass_backside/pass_backside_page.dart b/app/lib/pass_backside/pass_backside_page.dart index 33db1a2..b32b44f 100644 --- a/app/lib/pass_backside/pass_backside_page.dart +++ b/app/lib/pass_backside/pass_backside_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; -import 'dart:typed_data'; import 'package:app/db/db.dart'; import 'package:app/db/pass_entry.dart'; @@ -17,10 +16,10 @@ import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:geocoding/geocoding.dart' as geocoding; import 'package:passkit/passkit.dart'; import 'package:passkit_ui/passkit_ui.dart'; +import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:path/path.dart' as p; class PassBackSidePageArgs { PassBackSidePageArgs(this.pass, this.showDelete); @@ -49,7 +48,6 @@ class PassBacksidePage extends StatefulWidget { PassType.eventTicket => pass.pass.eventTicket?.backFields, PassType.generic => pass.pass.generic?.backFields, PassType.storeCard => pass.pass.storeCard?.backFields, - PassType.unknown => null, }; } } @@ -229,7 +227,7 @@ class _PassBacksidePageState extends State { PassEntry( id: updatedPass.pass.serialNumber, description: updatedPass.pass.description, - pass: Uint8List.fromList(updatedPass.sourceData), + pass: updatedPass.sourceData!, ), ); @@ -237,7 +235,7 @@ class _PassBacksidePageState extends State { } void _sharePass() { - final data = Uint8List.fromList(widget.pass.sourceData); + final data = widget.pass.sourceData!; Share.shareXFiles([XFile.fromData(data)]); } diff --git a/app/pubspec.lock b/app/pubspec.lock index c428797..73a483b 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -5,18 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "72.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.7.0" android_intent_plus: dependency: "direct main" description: @@ -109,18 +114,18 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + sha256: dd09dd4e2b078992f42aac7f1a622f01882a8492fef08486b27ddde929c19f04 url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.12" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "7.3.2" built_collection: dependency: transitive description: @@ -225,14 +230,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" dart_style: dependency: transitive description: @@ -593,18 +590,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -645,6 +642,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" matcher: dependency: transitive description: @@ -657,18 +662,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" mime: dependency: transitive description: @@ -707,7 +712,7 @@ packages: path: "../passkit" relative: true source: path - version: "0.0.6" + version: "0.0.7" passkit_ui: dependency: "direct main" description: @@ -831,10 +836,10 @@ packages: dependency: transitive description: name: process_run - sha256: c917dfb5f7afad4c7485bc00a4df038621248fce046105020cea276d1a87c820 + sha256: "112a77da35be50617ed9e2230df68d0817972f225e7f97ce8336f76b4e601606" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" pub_semver: dependency: transitive description: @@ -1044,10 +1049,10 @@ packages: dependency: transitive description: name: sqflite_common - sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + sha256: "7b41b6c3507854a159e24ae90a8e3e9cc01eb26a477c118d6dca065b5f55453e" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.4+2" sqflite_common_ffi: dependency: transitive description: @@ -1060,10 +1065,10 @@ packages: dependency: transitive description: name: sqflite_common_ffi_web - sha256: e9d1cb35a5ff7c43072968ed734e0a1a859564fd2b2c8654e0c6244a57dc82a8 + sha256: "5aa15408f29eca8cc8dcca653c38d66cf9a5fb5a2c1e9826a75ce4ae4938dec1" url: "https://pub.dev" source: hosted - version: "0.4.4" + version: "0.4.5+2" sqlite3: dependency: transitive description: @@ -1132,10 +1137,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + sha256: a824e842b8a054f91a728b783c177c1e4731f6b124f9192468457a8913371255 url: "https://pub.dev" source: hosted - version: "3.1.0+1" + version: "3.2.0" term_glyph: dependency: transitive description: @@ -1148,10 +1153,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" timezone: dependency: transitive description: @@ -1196,10 +1201,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: f0c73347dfcfa5b3db8bc06e1502668265d39c08f310c29bff4e28eea9699f79 + sha256: e35a698ac302dd68e41f73250bd9517fe3ab5fa4f18fe4647a0872db61bacbab url: "https://pub.dev" source: hosted - version: "6.3.9" + version: "6.3.10" url_launcher_ios: dependency: transitive description: @@ -1268,10 +1273,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" wakelock_plus: dependency: "direct main" description: @@ -1353,5 +1358,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.3" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.1" diff --git a/apple_passkit/example/pubspec.lock b/apple_passkit/example/pubspec.lock index d7f4733..edbb800 100644 --- a/apple_passkit/example/pubspec.lock +++ b/apple_passkit/example/pubspec.lock @@ -109,18 +109,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -149,18 +149,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -173,10 +173,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" process: dependency: transitive description: @@ -242,10 +242,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" vector_math: dependency: transitive description: @@ -258,10 +258,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" webdriver: dependency: transitive description: @@ -271,5 +271,5 @@ packages: source: hosted version: "3.0.3" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/passkit/CHANGELOG.md b/passkit/CHANGELOG.md index 95c37d1..41b1329 100644 --- a/passkit/CHANGELOG.md +++ b/passkit/CHANGELOG.md @@ -1,3 +1,37 @@ +## Unreleased + +- No longer mark `PkPass.write()` as experimental + +## 0.0.10 + +- Only support `image: ^4.1.1` + +## 0.0.9 + +- Increase [`intl`](https://pub.dev/packages/intl) version range, which makes it compatible with older Flutter versions +- Allow older versions of `archive`, `image`, `json_annotation`, `pointycastle` which should make version conflicts less likely +- Allow the library to be used on Dart 3.3 and newer +- Add convenience methods to create PkImage objects + - `createIcon()` creates, scales and converts a given image to an icon according to the spec + - `createLogo()` creates, scales and converts a given image to a logo according to the spec + - `createFooter()` creates, scales and converts a given image to a footer according to the spec +- Add a couple more `copyWith` methods to various classes +- Add a `createEventWithThumbnail()` method, which creates an opinionated event PkPass object. + +## 0.0.8 + +- Make it possible to override the bundled Apple WWDR G4 certificate + - Use the `overrideWwdrCert` argument of `PkPass.fromBytes()` + - Use the `overrideWwdrCert` argument of `PkOrder.fromBytes()` +- Added the ability to create localized passes +- Remove dependencies on the `encrypt` and `asn1lib` packages. + +## 0.0.7 + +- The library is now able to create properly signed `pkpass` files that work with Apple Wallet. + Follow the guide [here](https://github.com/ueman/passkit/blob/master/passkit/SIGNING.md) to learn more. +- Pretty much every use of `List` has been changed to `Uint8List`. This is potentially breaking. + ## 0.0.6 - Add ability to create PkPass signature via OpenSSL or other command line tools diff --git a/passkit/README.md b/passkit/README.md index 0dd8064..5d5a208 100644 --- a/passkit/README.md +++ b/passkit/README.md @@ -65,26 +65,84 @@ void main() { } ``` -## How to write a PassKit file? +## How to create a PassKit file? -> [!WARNING] -> This is experimental. -> The resulting file not yet get accepted by Apple Wallet due to missing support for writing the pass signature. +> [!IMPORTANT] +> Follow the guide [here](https://github.com/ueman/passkit/blob/master/passkit/SIGNING.md) to learn more about the signing process. This is a requirement before you can create a pass file. > -> If you know how to create the PkPass signature it in pure Dart code, please add an example -> [here](https://github.com/ueman/passkit/issues/74) or create -> a PR for [this](https://github.com/ueman/passkit/issues/74) issue. +> Apple's documentation [here](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html) explains which fields to set. ```dart import 'package:passkit/passkit.dart'; void main() { + final pass = PkPass(...); + final binaryData = pass.write( + certificatePem: File('pass_certificate.pem').readAsStringSync(), + privateKeyPem: File('private_key.pem').readAsStringSync(), + ); + File('pass.pkpass').writeAsBytesSync(binaryData); +} +``` + +If the resulting file doesn't work, please look into the [troubleshooting guide](https://github.com/ueman/passkit/blob/master/passkit/TROUBLESHOOTING.md). + +
+ shelf example + +A Hello World like example with shelf looks something like this: + +```dart +import 'package:shelf/shelf.dart'; +import 'package:passkit/passkit.dart'; + +Response onRequest(Request request) { + final pkPass = PkPass(...); + final bytes = pkPass.write( + certificatePem: File('passcertificate.pem').readAsStringSync(), + privateKeyPem: File('passwordless_key.pem').readAsStringSync(), + ); + + return Response.ok( + bytes, + headers: { + 'Content-type': 'application/vnd.apple.pkpass', + 'Content-disposition': 'attachment; filename=pass.pkpass', + }, + ); +} +``` + +
+ +
+ dart_frog example + +A Hello World like example with dart_frog looks something like this: + +```dart +import 'package:dart_frog/dart_frog.dart'; +import 'package:passkit/passkit.dart'; + +Response onRequest(RequestContext context) { final pkPass = PkPass(...); - final pkPassFile = pass.write(); - await File('path/to/pass.pkpass').writeAsBytes(pkPassFile); + final bytes = pkPass.write( + certificatePem: File('passcertificate.pem').readAsStringSync(), + privateKeyPem: File('passwordless_key.pem').readAsStringSync(), + ); + + return Response.bytes( + body: bytes, + headers: { + 'Content-type': 'application/vnd.apple.pkpass', + 'Content-disposition': 'attachment; filename=pass.pkpass', + }, + ); } ``` +
+ ## Signature & Checksums In case iOS runs into an issue with a PkPass it just shows a generic error message. This library is able to point out a more specific error, if a PkPass is malformatted, signed, or whatever. @@ -133,10 +191,8 @@ void main() { Please feel encouraged to create PRs for the following features. -- PassKit Web Service: This functionality is existing, but might not work. Please file an issue or create a PR with a fix for bugs you encounter. - - Push Notification update registration is only working on iOS due to this whole specification being an Apple thingy. +- Push Notification update registration is only working on iOS due to it being an exclusive Apple thingy. - Localization: Existing, but still inconvenient to use. There might be issues due to localizations being UTF-16 formatted, but the library currently uses UTF-8 to read localizations. -- [Passkit creation](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW54) is partially supported. See note further above. ## Bugs and parsing issues diff --git a/passkit/SIGNING.md b/passkit/SIGNING.md new file mode 100644 index 0000000..310acb7 --- /dev/null +++ b/passkit/SIGNING.md @@ -0,0 +1,130 @@ +# Signing a PkPass file + +> [!WARNING] +> THIS SHOULD NOT BE USED WITHIN AN APP. +> IT SHOULD ONLY BE USED SERVER SIDE, OTHERWISE YOU RISK EXPOSING YOUR CERTIFICATE AND PRIVATE KEY. + +This guide assumes you're working on a macOS system. + +# Passes + +## Step 1: Get a Pass ID and Pass Certificate from the Apple Developer Portal + +### Create a Pass Type Identifier +You build one or more groups of related passes, such as the tickets for different events. Each group has a unique pass type identifier, a reverse DNS string that identifies your organization, the kind of pass, and the group, such as `com.example-company.passes.ticket.event-4631A`. + +Identify the individual passes in a group by assigning each a serial number. Each combination of pass identifier and serial number is one unique pass. Adding a pass with the same pass identifier and serial number as one that already exists on a users’ device overwrites the old one. + +Create your pass type identifier in the Certificates, Identifiers & Profiles area of your Apple Developer account: + +- Select Identifiers and then click Add (+). +- On the next screen, choose Pass Type IDs and click Continue. +- Enter a description and the reverse DNS string to create the identifier. + +For more information on signing in to your account and creating identifiers, see [Developer Account Help](https://developer.apple.com/help/account/). + +Set the `passTypeIdentifier` of Pass in the `pass.json` file to the identifier. Set the `serialNumber` key to the unique serial number for that identifier. + +### Generate a Signing Certificate +Signing a pass requires a signing certificate for the pass type identifier. Before you can generate a signing certificate you need a certificate signing request (CSR). To learn how to generate a CSR, see [Create a certificate signing request](https://developer.apple.com/help/account/create-certificates/create-a-certificate-signing-request). + +Generate the signing certificate in the Certificates, Identifiers & Profiles area of the Apple Developer portal. + +- Select Certificates, and then click Add +. +- On the next screen, choose Pass Type ID Certificate and click Continue. +- Enter a name for the certificate and select the pass type ID from the dropdown menu. + +Click continue and upload the certificate signing request (CSR). + +After uploading the CSR, generate the certificate and download it to the machine used for signing the pass. + +For more information on signing into your account and creating signing certificates, see [Developer Account Help](https://developer.apple.com/help/account/). + +In case of error in this guide, please read the [official Apple Guide](https://developer.apple.com/documentation/walletpasses/building-a-pass). + +At the end you should have a certificate and private key in Keychain Access. + +## Step 2: Create `.pem` files + +This is the important step! + +Export your certificate and private key from Keychain Access as `Certificate.p12`. It will ask you to set a password. + +You should replace `` with your password in the following commands. +(The `--legacy` part at the end of the following command may or may not be needed depending on your openssl version) + +Create the certificate `.pem` file. +```shell +openssl pkcs12 -in Certificates.p12 -clcerts -nokeys -out pass_certificate.pem -passin pass: --legacy +``` + +Create the private key `.pem` file. Unfortunately, it's not yet possible to use a password protected private key file. +```shell +openssl pkcs12 -in Certificates.p12 -out private_key.pem -nocerts -nodes -passin pass: --legacy +``` + +If the generated `.pem` files do not start with `-----BEGIN RSA PRIVATE KEY-----` (or similar) delete all lines that come before that. +Otherwise, the code can't decode the `.pem` files, and signing may fail. + +Then you can the generated file to sign PkPass files: + +```dart +final pass = PkPass(...); +final binaryData pass.write( + certificatePem: File('pass_certificate.pem').readAsStringSync(), + privateKeyPem: File('private_key.pem').readAsStringSync(), +); +File('pass.pkpass').writeAsBytesSync(binaryData); // The file ending is important +``` + +Make sure that the pass type identifier and the team identifier matche your certificate. + +# Orders + +## Step 1: Get an ID and certificate from the Apple Developer Portal + +### Create an order type identifier +An order type identifier is a unique identifier for your company or brand. Create your order type identifier in the Certificates, Identifiers & Profiles area of the Apple Developer portal: + +- Select Identifiers and then click Add +. +- On the next screen, choose Order Type ID and click Continue. +- Enter a description and the reverse DNS string to create the order type identifier. + +For more information about signing in to your account and creating identifiers, see [Developer Account Help](https://help.apple.com/developer-account/). + +Set the `orderTypeIdentifier` in your `order.json` file to the identifier. Set the `orderIdentifier` key to a unique order identifier. The order identifier, in combination with the order type identifier, uniquely identifies an order within the system. For more information, see the top-level [Order](https://developer.apple.com/documentation/walletorders/order) object. + +### Generate a signing certificate +Signing an order package requires a signing certificate for the order type identifier. Before you can generate a signing certificate, you need a certificate signing request (CSR). To learn how to generate a CSR, see [Create a certificate signing request](https://help.apple.com/developer-account/#/devbfa00fef7). + +Generate the signing certificate in the Certificates, Identifiers & Profiles area of the Apple Developer portal. + +- Select Certificates and then click Add +. +- On the next screen, choose Order Type ID Certificate and click Continue. +- Select the Order Type ID from the dropdown menu. + +Click Continue and upload the certificate signing request (CSR). + +Install the generated certificate on the server you use for signing the order package. For more information about signing in to your account and creating signing certificates, see [Developer Account Help](https://help.apple.com/developer-account/). + +## Step 2: Create `.pem` files + +This is the important step! + +Export your certificate and private key from Keychain Access as `Certificate.p12`. It will ask you to set a password. + +You should replace `` with your password in the following commands. +(The `--legacy` part at the end of the following command may or may not be needed depending on your openssl version) + +Create the certificate `.pem` file. +```shell +openssl pkcs12 -in Certificates.p12 -clcerts -nokeys -out pass_certificate.pem -passin pass: --legacy +``` + +Create the private key `.pem` file. Unfortunately, it's not yet possible to use a password protected private key file. +```shell +openssl pkcs12 -in Certificates.p12 -out private_key.pem -nocerts -nodes -passin pass: --legacy +``` + +If the generated `.pem` files do not start with `-----BEGIN RSA PRIVATE KEY-----` (or similar) delete all lines that come before that. +Otherwise, the code can't decode the `.pem` files, and signing may fail. diff --git a/passkit/TROUBLESHOOTING.md b/passkit/TROUBLESHOOTING.md new file mode 100644 index 0000000..8a4c1d1 --- /dev/null +++ b/passkit/TROUBLESHOOTING.md @@ -0,0 +1,47 @@ +# Trouble Shooting Guide + +## Passes + +If your pass doesn’t build correctly, check whether the following are all true: +- The `pass.json` file contains all the required keys. +- The value of the `passTypeIdentifer` key in the `pass.json` file matches the pass type identifier of the signing certificate. +- The value of the `teamIdentifier` key in the pass.json file matches the Apple Developer account of the signing certificate. +- The machine signing the pass has a copy of the signing certificate. +- The certificate isn’t expired. +- The `manifest.json` contains all the source files, including those in subdirectories. +- The source contains all required images. +- The images are in the correct format. +- The `pass.json` and `manifest.json` files use the correct JSON syntax. +- Strings that require value formats are correct, such as the `value` and `attributedValue` keys of [PassFieldContent](https://developer.apple.com/documentation/walletpasses/passfieldcontent) which require an ISO 8601 date. +- The names of localization folders use the correct language and region identifiers. +- Each localization folder contains all localized image files. +- Each localization folder contain the `pass.strings` file for passes with localized strings. +- The `pass.strings` files use the correct syntax. +- The keys for localized strings in the `pass.json` file match those used in the `pass.strings` files. +- Each `pass.strings` file contains the same number of localized strings and uses the same keys. + +Next to that, you can use the Console app on macOS. Try opening your `.pkpass` file in an iOS Simulator or on a connected iPhone, and you should see logs in the Console app. +You can try searching for the pass type identifier, and you should see logs for your pass. + +## Orders + +If your order package doesn’t build correctly, check whether the following are all true: +- The `order.json` file contains all the required keys. For more information about required keys, see the top-level Order object. +- The value of the `orderTypeIdentifer` key in the `order.json` file matches the order type identifier of the signing certificate. +- The value of the `merchantIdentifier` key in the `order.json` file matches the Apple Developer account of the signing certificate. +- The server signing the order has a copy of the signing certificate and the WWDR Intermediate Certificates. +- The certificate isn’t expired. +- The `manifest.json` contains all the source files, including those in subdirectories. +- The images are in the correct format. +- The `order.json` and `manifest.json` files use the correct JSON syntax. +- Strings that require value formats are correct, such as the `createdAt` and `updatedAt` keys of Order, which require an RFC 3339 format, or `pickupWindowDuration`, which requires an ISO 8601-1 duration format. +- `updatedAt` is equal to `createdAt` if there are no updates, and `updatedAt` is monotonically increasing. +- Strings that require values from a finite set are correct, such as `Order.status` and `Order.ShippingFulfillment.status`. +- The names of localization folders use the correct language and region identifiers. For more information about language identifiers, see [Choosing localization regions and scripts](https://developer.apple.com/documentation/Xcode/choosing-localization-regions-and-scripts). +- Each localization folder contains all localized image files. +- Each localization folder contains the `order.strings` file for the order with localized strings. +- The keys for localized strings in the `order.json` file match those in the `order.strings` files. +- Each `order.strings` file contains the same number of localized strings and uses the same keys. + +Next to that, you can use the Console app on macOS. Try opening your `.order` file in an iOS Simulator or on a connected iPhone, and you should see logs in the Console app. +You can try searching for the order identifier, and you should see logs for your order file. diff --git a/passkit/lib/passkit.dart b/passkit/lib/passkit.dart index e3cfca4..b9bacda 100644 --- a/passkit/lib/passkit.dart +++ b/passkit/lib/passkit.dart @@ -1,6 +1,6 @@ library; -// Order +export 'src/crypto/apple_wwdr_certificate.dart'; export 'src/order/order_address.dart'; export 'src/order/order_application.dart'; export 'src/order/order_barcode.dart'; @@ -16,18 +16,19 @@ export 'src/order/order_return.dart'; export 'src/order/order_return_info.dart'; export 'src/order/order_shipping_fulfillment.dart'; export 'src/order/pk_order.dart'; -// PkPass +export 'src/pk_image.dart'; export 'src/pkpass/barcode.dart'; export 'src/pkpass/beacon.dart'; export 'src/pkpass/field_dict.dart'; +export 'src/pkpass/image_utilities.dart'; export 'src/pkpass/location.dart'; export 'src/pkpass/nfc.dart'; +export 'src/pkpass/opinionated_template.dart'; export 'src/pkpass/parse_utils.dart'; export 'src/pkpass/pass_data.dart'; export 'src/pkpass/pass_structure.dart'; export 'src/pkpass/pass_type.dart'; export 'src/pkpass/personalization.dart'; -export 'src/pkpass/pk_pass_image.dart'; export 'src/pkpass/pkpass.dart'; export 'src/pkpass/semantic_tag_type.dart'; export 'src/pkpass/semantics.dart'; diff --git a/passkit/lib/src/archive_extensions.dart b/passkit/lib/src/archive_extensions.dart index 2ff0e6a..3b47e09 100644 --- a/passkit/lib/src/archive_extensions.dart +++ b/passkit/lib/src/archive_extensions.dart @@ -1,41 +1,30 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; +import 'package:passkit/src/archive_file_extension.dart'; +import 'package:passkit/src/pk_image.dart'; import 'package:passkit/src/pkpass/exceptions.dart'; -import 'package:passkit/src/pkpass/pk_pass_image.dart'; -import 'package:passkit/src/strings_parser/naive_strings_file_parser.dart'; - -/// Dart uses a special fast decoder when using a fused [Utf8Decoder] and [JsonDecoder]. -/// This speeds up decoding. -/// See -/// - https://api.dart.dev/stable/3.4.4/dart-convert/Utf8Decoder-class.html -/// - https://github.com/dart-lang/sdk/blob/5b2ea0c7a227d91c691d2ff8cbbeb5f7f86afdb9/sdk/lib/_internal/vm/lib/convert_patch.dart#L40 -final _utf8JsonDecoder = const Utf8Decoder().fuse(const JsonDecoder()); +import 'package:passkit/src/strings/naive_strings_file_parser.dart'; +import 'package:passkit/src/utils.dart'; extension ArchiveX on Archive { - List? findBytesForFile(String fileName) => - findFile(fileName)?.content as List?; - - Uint8List? findUint8ListForFile(String fileName) { - final data = findBytesForFile(fileName); - return data == null ? null : Uint8List.fromList(data); - } + Uint8List? findBytesForFile(String fileName) => + findFile(fileName)?.binaryContent; Map? findFileAndReadAsJson(String fileName) { final bytes = findBytesForFile(fileName); if (bytes == null) { return null; } - return _utf8JsonDecoder.convert(bytes) as Map?; + return utf8JsonDecode(bytes); } PkImage? loadImage(String name) { return PkImage.fromImages( - image1: findUint8ListForFile('$name.png'), - image2: findUint8ListForFile('$name@2.png'), - image3: findUint8ListForFile('$name@3.png'), + image1: findBytesForFile('$name.png'), + image2: findBytesForFile('$name@2.png'), + image3: findBytesForFile('$name@3.png'), ); } @@ -52,8 +41,7 @@ extension ArchiveX on Archive { for (final languageFile in translationFiles) { final language = languageFile.name.split('.').first; - languageData[language] = - parseStringsFile(languageFile.content as List); + languageData[language] = parseStringsFile(languageFile.binaryContent); } return languageData; } @@ -88,10 +76,26 @@ extension ArchiveX on Archive { for (final file in filesWithoutSignatureAndManifest) { final checksumInManifest = manifest[file.name] as String?; - final digest = sha1.convert(file.content as List); + final digest = sha1.convert(file.binaryContent); if (checksumInManifest != digest.toString()) { throw ChecksumMismatchException(file.name); } } } + + Uint8List createManifest() { + final manifest = {}; + for (final file in files) { + manifest[file.name] = sha1.convert(file.binaryContent).toString(); + } + + final manifestContent = utf8JsonEncode(manifest); + final manifestFile = ArchiveFile( + 'manifest.json', + manifestContent.length, + manifestContent, + ); + addFile(manifestFile); + return Uint8List.fromList(manifestContent); + } } diff --git a/passkit/lib/src/archive_file_extension.dart b/passkit/lib/src/archive_file_extension.dart new file mode 100644 index 0000000..38d0a8d --- /dev/null +++ b/passkit/lib/src/archive_file_extension.dart @@ -0,0 +1,7 @@ +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; + +extension ArchiveFileX on ArchiveFile { + Uint8List get binaryContent => Uint8List.fromList(content as List); +} diff --git a/passkit/lib/src/apple_wwdr_certificate.dart b/passkit/lib/src/crypto/apple_wwdr_certificate.dart similarity index 92% rename from passkit/lib/src/apple_wwdr_certificate.dart rename to passkit/lib/src/crypto/apple_wwdr_certificate.dart index 129d8e4..621574f 100644 --- a/passkit/lib/src/apple_wwdr_certificate.dart +++ b/passkit/lib/src/crypto/apple_wwdr_certificate.dart @@ -1,8 +1,17 @@ import 'dart:typed_data'; +import 'package:meta/meta.dart'; import 'package:pkcs7/pkcs7.dart'; -X509 get wwdrG4 => +/// This is the content of https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer +/// +/// You can override this property to use a different certificate. Make sure to +/// override it before reading or creating any PkPass or Order files. +/// +/// You can find other certificates at: +/// - https://developer.apple.com/help/account/reference/wwdr-intermediate-certificates/ +/// - https://www.apple.com/certificateauthority/ +final X509 wwdrG4 = X509.fromDer(Uint8List.fromList(worldwide_Developer_Relations_G4)); /// This is the content of https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer . @@ -12,8 +21,9 @@ X509 get wwdrG4 => /// More info at: /// https://developer.apple.com/help/account/reference/wwdr-intermediate-certificates/ /// https://www.apple.com/certificateauthority/ -// ignore: constant_identifier_names -const worldwide_Developer_Relations_G4 = [ +@internal +// ignore: constant_identifier_names, non_constant_identifier_names +final worldwide_Developer_Relations_G4 = Uint8List.fromList([ 48, 130, 4, @@ -1127,4 +1137,4 @@ const worldwide_Developer_Relations_G4 = [ 207, 242, 159, -]; +]); diff --git a/passkit/lib/src/crypto/certificate_extension.dart b/passkit/lib/src/crypto/certificate_extension.dart new file mode 100644 index 0000000..63be05e --- /dev/null +++ b/passkit/lib/src/crypto/certificate_extension.dart @@ -0,0 +1,14 @@ +import 'package:collection/collection.dart'; +import 'package:pkcs7/pkcs7.dart'; + +extension CertX on X509 { + /// Matches the pass type identifer for PkPass and the merchant identifier for + /// orders + String? get identifier => + subject.firstWhereOrNull((it) => it.key.name == 'UID')?.value as String?; + + /// Matches the team identifier + String? get teamIdentifier => subject + .firstWhereOrNull((it) => it.key.name == 'organizationalUnitName') + ?.value as String?; +} diff --git a/passkit/lib/src/crypto/rsa_key_parser.dart b/passkit/lib/src/crypto/rsa_key_parser.dart new file mode 100644 index 0000000..9c98671 --- /dev/null +++ b/passkit/lib/src/crypto/rsa_key_parser.dart @@ -0,0 +1,78 @@ +import 'dart:convert' as convert; +import 'dart:typed_data'; + +import 'package:pointycastle/pointycastle.dart'; + +/// RSA PEM parser. +/// Inspired by https://pub.dev/documentation/encrypt/latest/encrypt/RSAKeyParser-class.html +/// but adapted to make use of pointycastle instad of asn1lib. +class RSAKeyParser { + /// Parses the PEM key no matter it is public or private, it will figure it out. + RSAAsymmetricKey parse(String key) { + final rows = key.split(RegExp(r'\r\n?|\n')); + final header = rows.first; + + if (header == '-----BEGIN RSA PUBLIC KEY-----') { + return _parsePublic(_parseSequence(rows)); + } + + if (header == '-----BEGIN PUBLIC KEY-----') { + return _parsePublic(_pkcs8PublicSequence(_parseSequence(rows))); + } + + if (header == '-----BEGIN RSA PRIVATE KEY-----') { + return _parsePrivate(_parseSequence(rows)); + } + + if (header == '-----BEGIN PRIVATE KEY-----') { + return _parsePrivate(_pkcs8PrivateSequence(_parseSequence(rows))); + } + + throw FormatException('Unable to parse key, invalid format.', header); + } + + RSAAsymmetricKey _parsePublic(ASN1Sequence sequence) { + final modulus = (sequence.elements![0] as ASN1Integer).integer!; + final exponent = (sequence.elements![1] as ASN1Integer).integer!; + + return RSAPublicKey(modulus, exponent); + } + + RSAAsymmetricKey _parsePrivate(ASN1Sequence sequence) { + final modulus = (sequence.elements![1] as ASN1Integer).integer!; + final exponent = (sequence.elements![3] as ASN1Integer).integer!; + final p = (sequence.elements![4] as ASN1Integer).integer!; + final q = (sequence.elements![5] as ASN1Integer).integer!; + + return RSAPrivateKey(modulus, exponent, p, q); + } + + ASN1Sequence _parseSequence(List rows) { + final keyText = rows + .skipWhile((row) => row.startsWith('-----BEGIN')) + .takeWhile((row) => !row.startsWith('-----END')) + .map((row) => row.trim()) + .join(''); + + final keyBytes = Uint8List.fromList(convert.base64.decode(keyText)); + final asn1Parser = ASN1Parser(keyBytes); + + return asn1Parser.nextObject() as ASN1Sequence; + } + + ASN1Sequence _pkcs8PublicSequence(ASN1Sequence sequence) { + final ASN1Object bitString = sequence.elements![1]; + final bytes = bitString.valueBytes!.sublist(1); + final parser = ASN1Parser(Uint8List.fromList(bytes)); + + return parser.nextObject() as ASN1Sequence; + } + + ASN1Sequence _pkcs8PrivateSequence(ASN1Sequence sequence) { + final ASN1Object bitString = sequence.elements![2]; + final bytes = bitString.valueBytes; + final parser = ASN1Parser(bytes); + + return parser.nextObject() as ASN1Sequence; + } +} diff --git a/passkit/lib/src/signature_verification.dart b/passkit/lib/src/crypto/signature_verification.dart similarity index 79% rename from passkit/lib/src/signature_verification.dart rename to passkit/lib/src/crypto/signature_verification.dart index bad8288..058fa75 100644 --- a/passkit/lib/src/signature_verification.dart +++ b/passkit/lib/src/crypto/signature_verification.dart @@ -1,9 +1,11 @@ import 'dart:typed_data'; + +import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; -import 'package:passkit/src/apple_wwdr_certificate.dart'; +import 'package:passkit/src/crypto/apple_wwdr_certificate.dart'; +import 'package:passkit/src/crypto/certificate_extension.dart'; import 'package:passkit/src/pkpass/exceptions.dart'; import 'package:pkcs7/pkcs7.dart'; -import 'package:collection/collection.dart'; /// [identifier] corresponds to the `passTypeIdentifier` in PkPasses or the /// `orderTypeIdentifier` for PkOrders. @@ -17,11 +19,12 @@ import 'package:collection/collection.dart'; // as long as the contents match? bool verifySignature({ required Uint8List signatureBytes, - required List manifestBytes, + required Uint8List manifestBytes, required String identifier, required String teamIdentifier, DateTime? now, bool checkOutdatedIssuerCerts = true, + X509? overrideWwdrCert, }) { final manifestHash = Uint8List.fromList(sha256.convert(manifestBytes).bytes); final pkcs7 = Pkcs7.fromDer(signatureBytes); @@ -50,23 +53,14 @@ bool verifySignature({ // Set the passTypeIdentifier of Pass in the pass.json file to the identifier. // Set the serialNumber key to the unique serial number for that identifier. - final signerInfo = pkcs7.verify([wwdrG4]); - // final algo = si.getDigest(si.digestAlgorithm); Calculate hash based on the algo? + final signerInfo = pkcs7.verify([overrideWwdrCert ?? wwdrG4]); return signerInfo.listEquality(manifestHash, signerInfo.messageDigest!); } bool Function(X509) _verifier(String teamIdentifier, String identifier) { return (X509 x509) { - final identifierMatches = - x509.subject.firstWhereOrNull((it) => it.key.name == 'UID')?.value == - identifier; - - final teamIdentifierMatches = x509.subject - .firstWhereOrNull( - (it) => it.key.name == 'organizationalUnitName', - ) - ?.value == - teamIdentifier; + final identifierMatches = x509.identifier == identifier; + final teamIdentifierMatches = x509.teamIdentifier == teamIdentifier; return identifierMatches && teamIdentifierMatches; }; diff --git a/passkit/lib/src/crypto/write_signature.dart b/passkit/lib/src/crypto/write_signature.dart new file mode 100644 index 0000000..685a887 --- /dev/null +++ b/passkit/lib/src/crypto/write_signature.dart @@ -0,0 +1,88 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:passkit/src/crypto/apple_wwdr_certificate.dart'; +import 'package:passkit/src/crypto/certificate_extension.dart'; +import 'package:passkit/src/crypto/rsa_key_parser.dart'; +import 'package:passkit/src/pkpass/exceptions.dart'; +import 'package:pkcs7/pkcs7.dart'; +import 'package:pointycastle/pointycastle.dart'; + +/// [certificatePem] is the certificate to be used to sign the PkPass file. +/// +/// [privateKeyPem] is the private key PEM file. Right now, +/// it's only supported if it's not password protected. +/// +/// Read more about signing [here](https://github.com/ueman/passkit/blob/master/passkit/SIGNING.md). +Uint8List writeSignature( + String certificatePem, + String privateKeyPem, + Uint8List manifestBytes, + String identifier, + String teamIdentifier, + bool isPkPass, + X509? overrideWwdrCert, +) { + final issuer = X509.fromPem(certificatePem); + _ensureCertificateMatchesPass( + issuer, + identifier, + teamIdentifier, + isPkPass, + ); + + final pkcs7Builder = Pkcs7Builder(); + + pkcs7Builder.addCertificate(overrideWwdrCert ?? wwdrG4); + pkcs7Builder.addCertificate(issuer); + + final privateKey = RSAKeyParser().parse(privateKeyPem) as RSAPrivateKey; + + final signerInfo = Pkcs7SignerInfoBuilder.rsa( + issuer: issuer, + privateKey: privateKey, + digestAlgorithm: HashAlgorithm.sha256, + ); + + final manifestHash = Uint8List.fromList(sha256.convert(manifestBytes).bytes); + + signerInfo.addSMimeDigest( + digest: manifestHash, + signingTime: DateTime.now(), + ); + pkcs7Builder.addSignerInfo(signerInfo); + + final pkcs7 = pkcs7Builder.build(); + return pkcs7.der; +} + +void _ensureCertificateMatchesPass( + X509 issuer, + String identifier, + String teamIdentifier, + bool isPkPass, +) { + final identifierMatches = issuer.identifier == identifier; + final teamIdentifierMatches = issuer.teamIdentifier == teamIdentifier; + + if (!identifierMatches) { + if (isPkPass) { + throw Exception( + "PkPass.pass.passTypeIdentifier doesn't match the certificate Pass Type ID", + ); + } else { + // TODO(any): Write a proper exception for orders + throw SignatureMismatchException(); + } + } + if (!teamIdentifierMatches) { + if (isPkPass) { + throw Exception( + "PkPass.pass.teamIdentifier doesn't match the certificate team ID", + ); + } else { + // TODO(any): Write a proper exception for orders + throw SignatureMismatchException(); + } + } +} diff --git a/passkit/lib/src/order/pk_order.dart b/passkit/lib/src/order/pk_order.dart index bb32f2c..350e0df 100644 --- a/passkit/lib/src/order/pk_order.dart +++ b/passkit/lib/src/order/pk_order.dart @@ -2,9 +2,11 @@ import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:passkit/src/archive_extensions.dart'; +import 'package:passkit/src/archive_file_extension.dart'; +import 'package:passkit/src/crypto/signature_verification.dart'; +import 'package:passkit/src/pk_image.dart'; import 'package:passkit/src/pkpass/exceptions.dart'; -import 'package:passkit/src/pkpass/pk_pass_image.dart'; -import 'package:passkit/src/signature_verification.dart'; +import 'package:pkcs7/pkcs7.dart'; import 'order_data.dart'; @@ -17,13 +19,14 @@ class PkOrder { }); /// Parses bytes to a [PkOrder] instance. - /// Setting [skipVerification] to true disables any checksum or signature - /// verification and validation. + /// Setting [skipChecksumVerification] and [skipSignatureVerification] to true + /// disables checksum or signature verification and validation. // TODO(ueman): Provide an async method for this. static PkOrder fromBytes( - final List bytes, { + final Uint8List bytes, { bool skipChecksumVerification = false, bool skipSignatureVerification = false, + X509? overrideWwdrCert, }) { if (bytes.isEmpty) { throw EmptyBytesException(); @@ -39,17 +42,18 @@ class PkOrder { if (skipSignatureVerification) { final manifestContent = - archive.findFile('manifest.json')!.content as List; - final signatureContent = Uint8List.fromList( - archive.findFile('signature')!.content as List, - ); + archive.findFile('manifest.json')!.binaryContent; + final signatureContent = archive.findFile('signature')!.binaryContent; - verifySignature( + if (!verifySignature( signatureBytes: signatureContent, manifestBytes: manifestContent, identifier: order.orderTypeIdentifier, teamIdentifier: order.merchant.merchantIdentifier, - ); + overrideWwdrCert: overrideWwdrCert, + )) { + throw Exception('validation failed'); + } } } @@ -98,7 +102,7 @@ class PkOrder { } /// The bytes of this PkPass - final List sourceData; + final Uint8List sourceData; /// Indicates whether a webservices is available. bool get isWebServiceAvailable => order.webServiceURL != null; diff --git a/passkit/lib/src/pkpass/pk_pass_image.dart b/passkit/lib/src/pk_image.dart similarity index 100% rename from passkit/lib/src/pkpass/pk_pass_image.dart rename to passkit/lib/src/pk_image.dart diff --git a/passkit/lib/src/pkpass/barcode.dart b/passkit/lib/src/pkpass/barcode.dart index 2842927..8b949b0 100644 --- a/passkit/lib/src/pkpass/barcode.dart +++ b/passkit/lib/src/pkpass/barcode.dart @@ -42,6 +42,20 @@ class Barcode { /// Converts this instance to a JSON object Map toJson() => _$BarcodeToJson(this); + + Barcode copyWith({ + String? altText, + PkPassBarcodeType? format, + String? message, + String? messageEncoding, + }) { + return Barcode( + altText: altText ?? this.altText, + format: format ?? this.format, + message: message ?? this.message, + messageEncoding: messageEncoding ?? this.messageEncoding, + ); + } } enum PkPassBarcodeType { diff --git a/passkit/lib/src/pkpass/beacon.dart b/passkit/lib/src/pkpass/beacon.dart index ccf43fb..78cb6a5 100644 --- a/passkit/lib/src/pkpass/beacon.dart +++ b/passkit/lib/src/pkpass/beacon.dart @@ -38,4 +38,18 @@ class Beacon { /// Converts this instance to a JSON object Map toJson() => _$BeaconToJson(this); + + Beacon copyWith({ + int? major, + int? minor, + String? proximityUUID, + String? relevantText, + }) { + return Beacon( + major: major ?? this.major, + minor: minor ?? this.minor, + proximityUUID: proximityUUID ?? this.proximityUUID, + relevantText: relevantText ?? this.relevantText, + ); + } } diff --git a/passkit/lib/src/pkpass/field_dict.dart b/passkit/lib/src/pkpass/field_dict.dart index 9b183c9..c039178 100644 --- a/passkit/lib/src/pkpass/field_dict.dart +++ b/passkit/lib/src/pkpass/field_dict.dart @@ -109,8 +109,9 @@ class FieldDict { @JsonKey(name: 'timeStyle') final DateStyle? timeStyle; - /// The style of the number to display in the field. Formatter styles have the same meaning as the formats with corresponding names in NumberFormatter.Style. - /// Possible Values: PKNumberStyleDecimal, PKNumberStylePercent, PKNumberStyleScientific, PKNumberStyleSpellOut + /// The style of the number to display in the field. Formatter styles have the + /// same meaning as the formats with corresponding names in + /// NumberFormatter.Style. @JsonKey(name: 'numberStyle') final NumberStyle? numberStyle; @@ -198,6 +199,42 @@ class FieldDict { // TODO(any): Could be localizable return value?.toString(); } + + FieldDict copyWith({ + String? attributedValue, + String? changeMessage, + List? dataDetectorTypes, + String? key, + String? label, + PkTextAlignment? textAlignment, + Object? value, + String? currencyCode, + DateStyle? dateStyle, + DateStyle? timeStyle, + NumberStyle? numberStyle, + bool? ignoresTimeZone, + bool? isRelative, + Semantics? semantics, + int? row, + }) { + return FieldDict( + attributedValue: attributedValue ?? this.attributedValue, + changeMessage: changeMessage ?? this.changeMessage, + dataDetectorTypes: dataDetectorTypes ?? this.dataDetectorTypes, + key: key ?? this.key, + label: label ?? this.label, + textAlignment: textAlignment ?? this.textAlignment, + value: value ?? this.value, + currencyCode: currencyCode ?? this.currencyCode, + dateStyle: dateStyle ?? this.dateStyle, + timeStyle: timeStyle ?? this.timeStyle, + numberStyle: numberStyle ?? this.numberStyle, + ignoresTimeZone: ignoresTimeZone ?? this.ignoresTimeZone, + isRelative: isRelative ?? this.isRelative, + semantics: semantics ?? this.semantics, + row: row ?? this.row, + ); + } } enum PkTextAlignment { diff --git a/passkit/lib/src/pkpass/image_utilities.dart b/passkit/lib/src/pkpass/image_utilities.dart new file mode 100644 index 0000000..8d59f1a --- /dev/null +++ b/passkit/lib/src/pkpass/image_utilities.dart @@ -0,0 +1,191 @@ +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; +import 'package:passkit/src/pk_image.dart'; + +/* +The pass layout allots a certain area on the front of the pass for each image. +Images are scaled (preserving aspect ratio) to fill this allotted space. +Images with a different aspect ratio than their allotted space are cropped after +being scaled. The space allotted is as follows: + +- The background image (background.png) is displayed behind the entire front of + the pass. The expected dimensions are 180 x 220 points. The image is cropped + slightly on all sides and blurred. Depending on the image, you can often + provide an image at a smaller size and let it be scaled up, because the blur + effect hides details. This lets you reduce the file size without a noticeable + difference in the pass. + +- The strip image (strip.png) is displayed behind the primary fields. + - On iPhone 6 and 6 Plus The allotted space is 375 x 98 points for event tickets, + 375 x 144 points for gift cards and coupons, and 375 x 123 in all other cases. + - On prior hardware The allotted space is 320 x 84 points for event tickets, + 320 x 110 points for other pass styles with a square barcode on devices with + 3.5 inch screens, and 320 x 123 in all other cases. + +- The thumbnail image (thumbnail.png) displayed next to the fields on the front + of the pass. The allotted space is 90 x 90 points. The aspect ratio should be + in the range of 2:3 to 3:2, otherwise the image is cropped. + +Note + +The dimensions given above are all in points. On a non-Retina display, each +point equals exactly 1 pixel. On a Retina display, there are 2 or 3 pixels per +point, depending on the device. To support all screen sizes and resolutions, +provide the original, @2x, and @3x versions of your art. + +Source https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html +*/ + +/// Creates all three image scales for the icon. +/// If you have localized images, you'll need to call this for every language. +/// +/// The icon is displayed when a pass is shown on the lock screen and +/// by apps such as Mail when showing a pass attached to an email. +/// The icon should measure +/// - 29 * 29 at 1x +/// - 58 * 58 at 2x +/// - 87 * 87 at 3x +PkImage createIcon(Uint8List data) { + final image = img.decodeImage(data); + if (image == null) { + throw Exception('Image could not be read'); + } + assert(image.width == image.height, 'Image must be square'); + + final images = []; + for (var i = 1; i <= 3; i++) { + final resized = img.copyResize( + image, + width: 29 * i, + height: 29 * i, + interpolation: img.Interpolation.cubic, + ); + images.add(img.encodePng(resized)); + } + + return PkImage( + image1: images[0], + image2: images[1], + image3: images[2], + ); +} + +/// Creates all three logo scales for the icon. +/// If you have localized images, you'll need to call this for every language. +/// +/// The logo image is displayed in the top left corner of the pass, +/// next to the logo text. +/// The allotted space is 160 x 50 points; in most cases it should be narrower. +/// +/// The logo should measure at max +/// - 160 * 50 at 1x +/// - 320 * 100 at 2x +/// - 480 * 150 at 3x +PkImage createLogo(Uint8List data) { + final image = img.decodeImage(data); + if (image == null) { + throw Exception('Image could not be read'); + } + + final images = []; + for (var i = 1; i <= 3; i++) { + final resized = img.copyResize( + image, + height: 50 * i, + interpolation: img.Interpolation.cubic, + maintainAspect: true, + ); + images.add(img.encodePng(resized)); + } + + return PkImage( + image1: images[0], + image2: images[1], + image3: images[2], + ); +} + +/// Creates all three logo scales for the icon. +/// If you have localized images, you'll need to call this for every language. +/// +/// The footer image (footer.png) is displayed near the barcode. +/// The allotted space is: +/// - 286 x 15 points at 1x +/// - 572 x 30 points at 2x +/// - 858 x 45 points at 3x +PkImage createFooter(Uint8List data) { + final image = img.decodeImage(data); + if (image == null) { + throw Exception('Image could not be read'); + } + assert(image.height % 15 == 0); + assert(image.width % 286 == 0); + + final images = []; + for (var i = 1; i <= 3; i++) { + final resized = img.copyResize( + image, + height: 15 * i, + width: 286 * i, + interpolation: img.Interpolation.cubic, + maintainAspect: true, + ); + images.add(img.encodePng(resized)); + } + + return PkImage( + image1: images[0], + image2: images[1], + image3: images[2], + ); +} + +/* + +/// Background data should be 375 x 123, or a multiple of that +// 375, 750, 1125 +// 123, 246, 369 +// 184 -> +// 225 +// +// +// 11px | +// 168px O-O-O-O-O- .... spacing 40 +// 11px | +// 168px O +// 11px | +// =369px +PkImage createStampCartStrip( + Uint8List backgroundData, + Uint8List stampData, + Uint8List stampFilledData, +) { + final background = img.decodeImage(backgroundData)!; + + final stamp = img.decodeImage(stampData)!; + assert(stamp.width == stamp.height, 'Image must be square'); + + final stampFilled = img.decodeImage(stampFilledData)!; + assert(stampFilled.width == stampFilled.height, 'Image must be square'); + + final images = []; + for (var i = 1; i <= 3; i++) { + final resized = img.copyResize( + image, + height: 123 * i, + width: 375 * i, + interpolation: img.Interpolation.cubic, + maintainAspect: true, + ); + images.add(img.encodePng(resized)); + } + + return PkImage( + image1: images[0], + image2: images[1], + image3: images[2], + ); +} + +*/ diff --git a/passkit/lib/src/pkpass/location.dart b/passkit/lib/src/pkpass/location.dart index d75f1fd..4aba0a0 100644 --- a/passkit/lib/src/pkpass/location.dart +++ b/passkit/lib/src/pkpass/location.dart @@ -34,4 +34,18 @@ class Location { /// “Store nearby on 1st and Main.” @JsonKey(name: 'relevantText') final String? relevantText; + + Location copyWith({ + double? altitude, + double? latitude, + double? longitude, + String? relevantText, + }) { + return Location( + altitude: altitude ?? this.altitude, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + relevantText: relevantText ?? this.relevantText, + ); + } } diff --git a/passkit/lib/src/pkpass/nfc.dart b/passkit/lib/src/pkpass/nfc.dart index bea39f9..086419d 100644 --- a/passkit/lib/src/pkpass/nfc.dart +++ b/passkit/lib/src/pkpass/nfc.dart @@ -36,4 +36,17 @@ class Nfc { /// the authentication requirement. @JsonKey(name: 'requiresAuthentication') final bool? requiresAuthentication; + + Nfc copyWith({ + String? message, + String? encryptionPublicKey, + bool? requiresAuthentication, + }) { + return Nfc( + message: message ?? this.message, + encryptionPublicKey: encryptionPublicKey ?? this.encryptionPublicKey, + requiresAuthentication: + requiresAuthentication ?? this.requiresAuthentication, + ); + } } diff --git a/passkit/lib/src/pkpass/opinionated_template.dart b/passkit/lib/src/pkpass/opinionated_template.dart new file mode 100644 index 0000000..afe30d3 --- /dev/null +++ b/passkit/lib/src/pkpass/opinionated_template.dart @@ -0,0 +1,75 @@ +import 'package:csslib/parser.dart'; +import 'package:passkit/passkit.dart'; + +/// Creates an opinionated [PkPass] for an event. +/// In order to further customize an event pass, use the various `copyWith` +/// methods available on the PkPass object and its various properties. +/// +/// Use the event ticket style to give people entry into events like concerts, +/// movies, plays, and sporting events. Typically, each pass corresponds to a +/// specific event, but you can also use a single pass for several events, +/// as with a season ticket. +/// +/// An event ticket can display logo, strip, background, or thumbnail images. +/// However, if you supply a strip image, don’t include a background or +/// thumbnail image. You can also include an extra row of up to four auxiliary +/// fields (for developer guidance, see the `row` property of [FieldDict.row]). +/// +/// The [primaryFields] should contain an event description. +/// The [secondaryFields] should contain the user's info. +/// +/// See als: +/// - https://developer.apple.com/design/human-interface-guidelines/wallet#Event-tickets +PkPass createEventWithThumbnail({ + required PkImage logo, + required PkImage icon, + PkImage? background, + PkImage? thumbnail, + required String description, + required String organizationName, + required String serialNumber, + List? associatedStoreIdentifiers, + required Color backgroundColor, + required Color foregroundColor, + required Color labelColor, + required Barcode barcode, + required List locations, + Uri? webServiceUri, + required String logoText, + DateTime? expirationDate, + DateTime? relevantTime, + required Semantics? semantics, + required List? primaryFields, + required List? secondaryFields, + required List? auxiliaryFields, +}) { + return PkPass( + pass: PassData( + passTypeIdentifier: 'passTypeIdentifier', + teamIdentifier: 'teamIdentifier', + description: description, + organizationName: organizationName, + serialNumber: serialNumber, + associatedStoreIdentifiers: associatedStoreIdentifiers, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + labelColor: labelColor, + barcode: barcode, + locations: locations, + webServiceURL: webServiceUri, + logoText: logoText, + expirationDate: expirationDate, + relevantDate: relevantTime, + semantics: semantics, + eventTicket: PassStructure( + primaryFields: primaryFields, + secondaryFields: secondaryFields, + auxiliaryFields: auxiliaryFields, + ), + ), + logo: logo, + icon: icon, + background: background, + thumbnail: thumbnail, + ); +} diff --git a/passkit/lib/src/pkpass/pass_data.dart b/passkit/lib/src/pkpass/pass_data.dart index 94b6957..110f66a 100644 --- a/passkit/lib/src/pkpass/pass_data.dart +++ b/passkit/lib/src/pkpass/pass_data.dart @@ -1,5 +1,6 @@ import 'package:csslib/parser.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:passkit/src/crypto/certificate_extension.dart'; import 'package:passkit/src/pkpass/barcode.dart'; import 'package:passkit/src/pkpass/beacon.dart'; import 'package:passkit/src/pkpass/location.dart'; @@ -7,6 +8,7 @@ import 'package:passkit/src/pkpass/nfc.dart'; import 'package:passkit/src/pkpass/parse_utils.dart'; import 'package:passkit/src/pkpass/pass_structure.dart'; import 'package:passkit/src/pkpass/semantics.dart'; +import 'package:pkcs7/pkcs7.dart'; part 'pass_data.g.dart'; @@ -14,11 +16,11 @@ part 'pass_data.g.dart'; class PassData { PassData({ required this.description, - required this.formatVersion, required this.organizationName, required this.passTypeIdentifier, required this.serialNumber, required this.teamIdentifier, + this.formatVersion = 1, this.appLaunchURL, this.associatedStoreIdentifiers, this.userInfo, @@ -48,6 +50,47 @@ class PassData { this.semantics, }); + /// Sets the [teamIdentifier] and [passTypeIdentifier] from a certificate pem + /// file. Otherwise, it's identical to the default constructor. + PassData.fromCertificate({ + required this.description, + required this.organizationName, + required this.serialNumber, + required String certificatePem, + this.formatVersion = 1, + this.appLaunchURL, + this.associatedStoreIdentifiers, + this.userInfo, + this.expirationDate, + this.voided, + this.beacons, + this.locations, + this.maxDistance, + this.relevantDate, + this.boardingPass, + this.coupon, + this.eventTicket, + this.generic, + this.storeCard, + this.barcode, + this.barcodes, + this.backgroundColor, + this.foregroundColor, + this.groupingIdentifier, + this.labelColor, + this.logoText, + this.suppressStripShine, + this.sharingProhibited, + this.authenticationToken, + this.webServiceURL, + this.nfc, + this.semantics, + }) : + // It's kinda stupid to parse it twice, but it works. + // TODO(any): Make this more performant + teamIdentifier = X509.fromPem(certificatePem).teamIdentifier!, + passTypeIdentifier = X509.fromPem(certificatePem).identifier!; + factory PassData.fromJson(Map json) => _$PassDataFromJson(json); @@ -180,7 +223,7 @@ class PassData { /// Optional. Information specific to the pass’s barcode. For this /// dictionary’s keys, see Barcode Dictionary Keys. - /// Note:Deprecated in iOS 9.0 and later; use barcodes instead. + /// Note: Deprecated in iOS 9.0 and later; use barcodes instead. @JsonKey(name: 'barcode') final Barcode? barcode; @@ -266,4 +309,87 @@ class PassData { /// a pass and suggest related actions. @JsonKey(name: 'semantics') final Semantics? semantics; + + PassData copyWith({ + String? description, + int? formatVersion, + String? organizationName, + String? passTypeIdentifier, + String? serialNumber, + String? teamIdentifier, + String? appLaunchURL, + List? associatedStoreIdentifiers, + Map? userInfo, + DateTime? expirationDate, + bool? voided, + List? beacons, + List? locations, + num? maxDistance, + DateTime? relevantDate, + PassStructure? boardingPass, + PassStructure? coupon, + PassStructure? eventTicket, + PassStructure? generic, + PassStructure? storeCard, + Barcode? barcode, + List? barcodes, + Color? backgroundColor, + Color? foregroundColor, + String? groupingIdentifier, + Color? labelColor, + String? logoText, + bool? suppressStripShine, + bool? sharingProhibited, + String? authenticationToken, + Uri? webServiceURL, + Nfc? nfc, + Semantics? semantics, + }) { + return PassData( + description: description ?? this.description, + formatVersion: formatVersion ?? this.formatVersion, + organizationName: organizationName ?? this.organizationName, + passTypeIdentifier: passTypeIdentifier ?? this.passTypeIdentifier, + serialNumber: serialNumber ?? this.serialNumber, + teamIdentifier: teamIdentifier ?? this.teamIdentifier, + appLaunchURL: appLaunchURL ?? this.appLaunchURL, + associatedStoreIdentifiers: + associatedStoreIdentifiers ?? this.associatedStoreIdentifiers, + userInfo: userInfo ?? this.userInfo, + expirationDate: expirationDate ?? this.expirationDate, + voided: voided ?? this.voided, + beacons: beacons ?? this.beacons, + locations: locations ?? this.locations, + maxDistance: maxDistance ?? this.maxDistance, + relevantDate: relevantDate ?? this.relevantDate, + boardingPass: boardingPass ?? this.boardingPass, + coupon: coupon ?? this.coupon, + eventTicket: eventTicket ?? this.eventTicket, + generic: generic ?? this.generic, + storeCard: storeCard ?? this.storeCard, + barcode: barcode ?? this.barcode, + barcodes: barcodes ?? this.barcodes, + backgroundColor: backgroundColor ?? this.backgroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + groupingIdentifier: groupingIdentifier ?? this.groupingIdentifier, + labelColor: labelColor ?? this.labelColor, + logoText: logoText ?? this.logoText, + suppressStripShine: suppressStripShine ?? this.suppressStripShine, + sharingProhibited: sharingProhibited ?? this.sharingProhibited, + authenticationToken: authenticationToken ?? this.authenticationToken, + webServiceURL: webServiceURL ?? this.webServiceURL, + nfc: nfc ?? this.nfc, + semantics: semantics ?? this.semantics, + ); + } + + /// Overrides the current [passTypeIdentifier] and [teamIdentifier] with + /// the IDs from the certificate PEM file. + PassData copyWithFieldsFromCertificate(String certificatePem) { + final issuer = X509.fromPem(certificatePem); + return copyWith( + passTypeIdentifier: issuer.identifier, + teamIdentifier: issuer.teamIdentifier, + ); + } } diff --git a/passkit/lib/src/pkpass/pass_type.dart b/passkit/lib/src/pkpass/pass_type.dart index 5ad1db8..3b1ea32 100644 --- a/passkit/lib/src/pkpass/pass_type.dart +++ b/passkit/lib/src/pkpass/pass_type.dart @@ -4,5 +4,4 @@ enum PassType { eventTicket, storeCard, generic, - unknown, } diff --git a/passkit/lib/src/pkpass/personalization.dart b/passkit/lib/src/pkpass/personalization.dart index 9097c64..93a8147 100644 --- a/passkit/lib/src/pkpass/personalization.dart +++ b/passkit/lib/src/pkpass/personalization.dart @@ -34,6 +34,19 @@ class Personalization { /// user. The signup form’s fields are generated based on these keys. @JsonKey(name: 'requiredPersonalizationFields') final List requiredPersonalizationFields; + + Personalization copyWith({ + String? description, + String? termsAndConditions, + List? requiredPersonalizationFields, + }) { + return Personalization( + description: description ?? this.description, + termsAndConditions: termsAndConditions ?? this.termsAndConditions, + requiredPersonalizationFields: + requiredPersonalizationFields ?? this.requiredPersonalizationFields, + ); + } } enum RequiredPersonalizationFields { diff --git a/passkit/lib/src/pkpass/pkpass.dart b/passkit/lib/src/pkpass/pkpass.dart index 1860c41..090db2b 100644 --- a/passkit/lib/src/pkpass/pkpass.dart +++ b/passkit/lib/src/pkpass/pkpass.dart @@ -1,40 +1,18 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:archive/archive.dart'; -import 'package:crypto/crypto.dart'; -import 'package:meta/meta.dart'; -import 'package:passkit/src/apple_wwdr_certificate.dart'; +import 'package:passkit/src/archive_extensions.dart'; +import 'package:passkit/src/archive_file_extension.dart'; +import 'package:passkit/src/crypto/signature_verification.dart'; +import 'package:passkit/src/crypto/write_signature.dart'; +import 'package:passkit/src/pk_image.dart'; import 'package:passkit/src/pkpass/exceptions.dart'; import 'package:passkit/src/pkpass/pass_data.dart'; import 'package:passkit/src/pkpass/pass_type.dart'; import 'package:passkit/src/pkpass/personalization.dart'; -import 'package:passkit/src/pkpass/pk_pass_image.dart'; -import 'package:passkit/src/signature_verification.dart'; -import 'package:passkit/src/strings_parser/naive_strings_file_parser.dart'; - -/// Follow [this](https://www.kodeco.com/2855-beginning-passbook-in-ios-6-part-1-2?page=4#toc-anchor-011) -/// tutorial for instructions on how to create the signature with OpenSSL. -/// -/// ```bash -/// /// openssl smime -binary -sign -certfile WWDR.pem -signer passcertificate.pem -inkey passkey.pem -in manifest.json -out signature -outform DER -passin pass:12345 -/// ``` -/// -/// [manifest] is the file you need to pass to OpenSSL as `manifest.json`. -/// [wwdrCertificate] is the file content you need to pass to OpenSSL as `WWDR.pem` -/// -/// If you know how to do it in pure Dart code, please add an example or create -/// a PR: https://github.com/ueman/passkit/issues/74 -typedef SignatureBuilder = Uint8List Function( - String manifest, - List wwdrCertificate, -); - -/// Dart uses a special fast decoder when using a fused [Utf8Decoder] and [JsonDecoder]. -/// This speeds up decoding. -/// See -/// - https://github.com/dart-lang/sdk/blob/5b2ea0c7a227d91c691d2ff8cbbeb5f7f86afdb9/sdk/lib/_internal/vm/lib/convert_patch.dart#L40 -final _utf8JsonDecoder = const Utf8Decoder().fuse(const JsonDecoder()); +import 'package:passkit/src/strings/strings_writer.dart'; +import 'package:passkit/src/utils.dart'; +import 'package:pkcs7/pkcs7.dart'; /// https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/Introduction.html /// https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195 @@ -58,8 +36,8 @@ final _utf8JsonDecoder = const Utf8Decoder().fuse(const JsonDecoder()); class PkPass { PkPass({ required this.pass, - required this.manifest, - required this.sourceData, + this.manifest, + this.sourceData, this.background, this.footer, this.icon, @@ -82,9 +60,10 @@ class PkPass { /// certificate. // TODO(any): Provide an async method for this. static PkPass fromBytes( - final List bytes, { + final Uint8List bytes, { bool skipChecksumVerification = false, bool skipSignatureVerification = false, + X509? overrideWwdrCert, }) { if (bytes.isEmpty) { throw EmptyBytesException(); @@ -99,17 +78,18 @@ class PkPass { archive.checkSha1Checksums(manifest); if (!skipSignatureVerification) { final manifestContent = - archive.findFile('manifest.json')!.content as List; - final signatureContent = Uint8List.fromList( - archive.findFile('signature')!.content as List, - ); + archive.findFile('manifest.json')!.binaryContent; + final signatureContent = archive.findFile('signature')!.binaryContent; - verifySignature( + if (!verifySignature( signatureBytes: signatureContent, manifestBytes: manifestContent, teamIdentifier: passData.teamIdentifier, identifier: passData.passTypeIdentifier, - ); + overrideWwdrCert: overrideWwdrCert, + )) { + throw Exception('validation failed'); + } } } @@ -123,13 +103,13 @@ class PkPass { // TODO(ueman): Images can be localized, too // Maybe it's better to have an on-demand API, something like // PkPass().getLogo(resolution: 3, languageCode: 'en_EN'). - logo: archive.loadImage('logo'), - icon: archive.loadImage('icon'), - footer: archive.loadImage('footer'), - thumbnail: archive.loadImage('thumbnail'), - strip: archive.loadImage('strip'), - background: archive.loadImage('background'), - personalizationLogo: archive.loadImage('personalizationLogo'), + logo: archive.loadPkPassImage('logo'), + icon: archive.loadPkPassImage('icon'), + footer: archive.loadPkPassImage('footer'), + thumbnail: archive.loadPkPassImage('thumbnail'), + strip: archive.loadPkPassImage('strip'), + background: archive.loadPkPassImage('background'), + personalizationLogo: archive.loadPkPassImage('personalizationLogo'), // source sourceData: bytes, ); @@ -137,20 +117,26 @@ class PkPass { /// Parses a `.pkpasses` to a list of [PkPass]es. /// The mimetype of that file is `application/vnd.apple.pkpasses`. - /// A `.pkpasses` file cna contain up to ten [PkPass]es. + /// A `.pkpasses` file can contain up to ten [PkPass]es. /// - /// Setting [skipVerification] to true disables any checksum or signature + /// Setting [skipChecksumVerification] to true disables any checksum /// verification and validation. /// + /// Setting [skipSignatureVerification] to true disables any signature + /// verification and validation. This may be needed for older passes which are + /// signed with an out of date [Apple WWDR](https://developer.apple.com/help/account/reference/wwdr-intermediate-certificates/) + /// certificate. + /// /// Read more at: /// - https://developer.apple.com/documentation/walletpasses/distributing_and_updating_a_pass#3793284 // TODO(ueman): Detect whether it's maybe just a single pass, and then // gracefully fall back to just parsing the PkPass file. // TODO(ueman): Provide an async method for this. static List passesFromBytes( - final List bytes, { + final Uint8List bytes, { bool skipChecksumVerification = false, bool skipSignatureVerification = false, + X509? overrideWwdrCert, }) { if (bytes.isEmpty) { throw EmptyBytesException(); @@ -162,9 +148,10 @@ class PkPass { return pkPasses .map( (file) => fromBytes( - file.content as List, + file.binaryContent, skipChecksumVerification: skipChecksumVerification, skipSignatureVerification: skipSignatureVerification, + overrideWwdrCert: overrideWwdrCert, ), ) .toList(); @@ -175,7 +162,7 @@ class PkPass { /// Mapping of files to their respective checksums. Typically not relevant for /// users of this package. - final Map manifest; + final Map? manifest; /// The [PassType] of this PkPass. PassType get type { @@ -194,7 +181,7 @@ class PkPass { if (pass.storeCard != null) { return PassType.storeCard; } - return PassType.unknown; + throw Exception('unknown pass type'); } /// The image displayed as the background of the front of the pass. @@ -240,10 +227,11 @@ class PkPass { /// pairs. /// The language identifier looks as described in /// https://developer.apple.com/documentation/xcode/choosing-localization-regions-and-scripts - final Map>? languageData; + final Map>? languageData; /// The bytes of this PkPass - final List sourceData; + /// Returns `null` when this instance wasn't read from a file. + final Uint8List? sourceData; /// Indicates whether a webservices is available. bool get isWebServiceAvailable => pass.webServiceURL != null; @@ -253,44 +241,49 @@ class PkPass { /// /// When written to disk, the file should have an ending of `.pkpass`. /// - /// In order to sign the pkpass file, pass a [signatureBuilder]. - /// Follow [this](https://www.kodeco.com/2855-beginning-passbook-in-ios-6-part-1-2?page=4#toc-anchor-011) - /// tutorial for instructions on how to create the signature with OpenSSL. - /// ```bash - /// openssl smime -binary -sign -certfile WWDR.pem -signer passcertificate.pem -inkey passkey.pem -in manifest.json -out signature -outform DER -passin pass:12345 - /// ``` + /// [certificatePem] is the certificate to be used to sign the PkPass file. + /// + /// [privateKeyPem] is the private key PEM file. Right now, + /// it's only supported if it's not password protected. + /// + /// Read more about signing [here](https://github.com/ueman/passkit/blob/master/passkit/SIGNING.md). + /// + /// If either [certificatePem] or [privateKeyPem] is null, the resulting PkPass + /// will not be properly signed, but still generated. /// - /// The file that's created by OpenSSL should be returned via [signatureBuilder]. + /// Setting [overrideWwdrCert] overrides the Apple WWDR certificate, that's + /// shipped with this library. /// - /// If you know how to do it in pure Dart code, please add an example or create - /// a PR: https://github.com/ueman/passkit/issues/74 + /// Apple's documentation [here](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html) + /// explains which fields to set for which type of pass. /// /// Remarks: - /// - There's no support for verifying that the signature matches the PkPass - /// - There's no support for localization - /// - There's no support for personalization /// - Image sizes aren't checked, which means it's possible to create passes /// that look odd and wrong in Apple wallet or [passkit_ui](https://pub.dev/packages/passkit_ui) - @experimental - Uint8List? write({SignatureBuilder? signatureBuilder}) { + Uint8List? write({ + required String? certificatePem, + required String? privateKeyPem, + X509? overrideWwdrCert, + }) { final archive = Archive(); - final encoder = JsonEncoder.withIndent(' '); - final passFile = ArchiveFile.string( + final passContent = utf8JsonEncode(pass.toJson()); + final passFile = ArchiveFile( 'pass.json', - encoder.convert(pass.toJson()), + passContent.length, + passContent, ); archive.addFile(passFile); - /* + if (personalization != null) { - encoder.addFile( - ArchiveFile.string( - 'personalization.json', - jsonEncode(personalization!.toJson()), - ), + final personalizationContent = utf8JsonEncode(personalization!.toJson()); + final personalizationFile = ArchiveFile( + 'personalization.json', + personalizationContent.length, + personalizationContent, ); + archive.addFile(personalizationFile); } - */ logo?.writeToArchive(archive, 'logo'); background?.writeToArchive(archive, 'background'); @@ -298,22 +291,33 @@ class PkPass { footer?.writeToArchive(archive, 'footer'); strip?.writeToArchive(archive, 'strip'); thumbnail?.writeToArchive(archive, 'thumbnail'); + personalizationLogo?.writeToArchive(archive, 'personalizationLogo'); + + final translationEntries = languageData?.entries; + if (translationEntries != null && translationEntries.isNotEmpty) { + // TODO(any): Ensure every translation file has the same amount of key value pairs. - final manifest = {}; - for (final file in archive.files) { - manifest[file.name] = sha1.convert(file.content as List).toString(); + for (final entry in translationEntries) { + final name = '${entry.key}.lproj/pass.strings'; + final localizationFile = + ArchiveFile.string(name, toStringsFile(entry.value)); + archive.addFile(localizationFile); + } } - final manifestContent = encoder.convert(manifest); - final manifestFile = ArchiveFile.string( - 'manifest.json', - manifestContent, - ); - archive.addFile(manifestFile); + final manifestFile = archive.createManifest(); + + if (certificatePem != null && privateKeyPem != null) { + final signature = writeSignature( + certificatePem, + privateKeyPem, + manifestFile, + pass.passTypeIdentifier, + pass.teamIdentifier, + true, + overrideWwdrCert, + ); - if (signatureBuilder != null) { - final signature = - signatureBuilder(manifestContent, worldwide_Developer_Relations_G4); final signatureFile = ArchiveFile( 'signature', signature.length, @@ -321,25 +325,45 @@ class PkPass { ); archive.addFile(signatureFile); } + final pkpass = ZipEncoder().encode(archive); - if (pkpass == null) { - return null; - } - return Uint8List.fromList(pkpass); + return pkpass == null ? null : Uint8List.fromList(pkpass); + } + + PkPass copyWith({ + PassData? pass, + Map? manifest, + PkImage? background, + PkImage? footer, + PkImage? icon, + PkImage? logo, + PkImage? strip, + PkImage? thumbnail, + PkImage? personalizationLogo, + Personalization? personalization, + Map>? languageData, + Uint8List? sourceData, + }) { + return PkPass( + pass: pass ?? this.pass, + manifest: manifest ?? this.manifest, + background: background ?? this.background, + footer: footer ?? this.footer, + icon: icon ?? this.icon, + logo: logo ?? this.logo, + strip: strip ?? this.strip, + thumbnail: thumbnail ?? this.thumbnail, + personalizationLogo: personalizationLogo ?? this.personalizationLogo, + personalization: personalization ?? this.personalization, + languageData: languageData ?? this.languageData, + sourceData: sourceData ?? this.sourceData, + ); } } // This is intentionally not exposed to keep this an implementation detail. // Tests should be written against the PkPass class directly. extension on Archive { - List? findBytesForFile(String fileName) => - findFile(fileName)?.content as List?; - - Uint8List? findUint8ListForFile(String fileName) { - final data = findBytesForFile(fileName); - return data == null ? null : Uint8List.fromList(data); - } - /// Returns a map of locale to a map of resolution to image bytes. /// Returns null, if no image is localized Map>? loadLocalizedImage(String imageName) { @@ -367,11 +391,11 @@ extension on Archive { } if (fileName.endsWith('@2x.png')) { - map[language]![2] = Uint8List.fromList(file.content as List); + map[language]![2] = file.binaryContent; } else if (fileName.endsWith('@3x.png')) { - map[language]![3] = Uint8List.fromList(file.content as List); + map[language]![3] = file.binaryContent; } else { - map[language]![1] = Uint8List.fromList(file.content as List); + map[language]![1] = file.binaryContent; } } @@ -390,37 +414,18 @@ extension on Archive { if (bytes == null) { return null; } - return _utf8JsonDecoder.convert(bytes) as Map?; + return utf8JsonDecode(bytes); } - PkImage? loadImage(String name) { + PkImage? loadPkPassImage(String name) { return PkImage.fromImages( - image1: findUint8ListForFile('$name.png'), - image2: findUint8ListForFile('$name@2x.png'), - image3: findUint8ListForFile('$name@3x.png'), + image1: findBytesForFile('$name.png'), + image2: findBytesForFile('$name@2x.png'), + image3: findBytesForFile('$name@3x.png'), localizedImages: loadLocalizedImage(name), ); } - Map> getTranslations() { - final languageData = >{}; - - // The Archive object doesn't have APIs to work with folders. - // Instead the file name contains a `/` indicating the file is within a folder. - // Example: `file.name == en.lproj/pass.strings` - final translationFiles = files - .where((element) => element.isFile) - .where((file) => file.name.endsWith('.lproj/pass.strings')); - - for (final languageFile in translationFiles) { - final language = languageFile.name.split('.').first; - - languageData[language] = - parseStringsFile(languageFile.content as List); - } - return languageData; - } - PassData readPass() { final passJson = findFileAndReadAsJson('pass.json'); if (passJson != null) { @@ -430,15 +435,6 @@ extension on Archive { } } - Map readManifest() { - final manifestJson = findFileAndReadAsJson('manifest.json'); - if (manifestJson != null) { - return manifestJson; - } else { - throw MissingManifestJsonException(); - } - } - Personalization? readPersonalization() { final personalizationFile = findFileAndReadAsJson('personalization.json'); if (personalizationFile != null) { @@ -446,34 +442,6 @@ extension on Archive { } return null; } - - /// To create the manifest file, recursively list the files in the package - /// (except the manifest file and signature), calculate the SHA-1 hash of the - /// contents of those files, and store the data in a dictionary. The keys are - /// relative paths to the file from the pass package. The values are the SHA-1 - /// hash (hex encoded) of the data at that path. Write this dictionary to the - /// file manifest.json at the top level of the pass package. - /// - /// https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW1 - void checkSha1Checksums(Map manifest) { - final filesWithoutSignatureAndManifest = files.where((file) { - return file.name != 'signature' && file.name != 'manifest.json'; - }).toList(); - - final fileInArchiveCount = filesWithoutSignatureAndManifest.length; - final manifestCount = manifest.length; - if (fileInArchiveCount != manifestCount) { - throw MissingChecksumException(); - } - - for (final file in filesWithoutSignatureAndManifest) { - final checksumInManifest = manifest[file.name] as String?; - final digest = sha1.convert(file.content as List); - if (checksumInManifest != digest.toString()) { - throw ChecksumMismatchException(file.name); - } - } - } } extension on PkImage { @@ -489,5 +457,23 @@ extension on PkImage { archive .addFile(ArchiveFile('$name@3x.png', image3!.lengthInBytes, image3)); } + + if (localizedImages != null) { + for (final entry in localizedImages!.entries) { + final lang = entry.key; + for (final image in entry.value.entries) { + final fileName = switch (image.key) { + 1 => '$lang.lproj/$name.png', + 2 => '$lang.lproj/$name@2x.png', + 3 => '$lang.lproj/$name@3x.png', + _ => throw Exception('This case should never happen'), + }; + + archive.addFile( + ArchiveFile(fileName, image.value.lengthInBytes, image.value), + ); + } + } + } } } diff --git a/passkit/lib/src/pkpass/semantic_tag_type.dart b/passkit/lib/src/pkpass/semantic_tag_type.dart index 80b694f..58dd8ce 100644 --- a/passkit/lib/src/pkpass/semantic_tag_type.dart +++ b/passkit/lib/src/pkpass/semantic_tag_type.dart @@ -17,6 +17,16 @@ class SemanticTagTypeWifiNetwork { /// The name for the WiFi network. @JsonKey(name: 'ssid') final String ssid; + + SemanticTagTypeWifiNetwork copyWith({ + String? password, + String? ssid, + }) { + return SemanticTagTypeWifiNetwork( + password: password ?? this.password, + ssid: ssid ?? this.ssid, + ); + } } /// An object that represents an amount of money and type of currency. @@ -40,6 +50,16 @@ class SemanticTagTypeCurrencyAmount { // ISO 4217 currency code as a string @JsonKey(name: 'currencyCode') final String? currencyCode; + + SemanticTagTypeCurrencyAmount copyWith({ + String? amount, + String? currencyCode, + }) { + return SemanticTagTypeCurrencyAmount( + amount: amount ?? this.amount, + currencyCode: currencyCode ?? this.currencyCode, + ); + } } /// An object that represents the coordinates of a location. @@ -59,6 +79,16 @@ class SemanticTagTypeLocation { /// The longitude, in degrees. @JsonKey(name: 'longitude') final double longitude; + + SemanticTagTypeLocation copyWith({ + double? latitude, + double? longitude, + }) { + return SemanticTagTypeLocation( + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } } /// An object that represents the identification of a seat for a transit journey @@ -108,6 +138,24 @@ class SemanticTagTypeSeat { // localizable string @JsonKey(name: 'seatType') final String? seatType; + + SemanticTagTypeSeat copyWith({ + String? seatDescription, + String? seatIdentifier, + String? seatNumber, + String? seatRow, + String? seatSection, + String? seatType, + }) { + return SemanticTagTypeSeat( + seatDescription: seatDescription ?? this.seatDescription, + seatIdentifier: seatIdentifier ?? this.seatIdentifier, + seatNumber: seatNumber ?? this.seatNumber, + seatRow: seatRow ?? this.seatRow, + seatSection: seatSection ?? this.seatSection, + seatType: seatType ?? this.seatType, + ); + } } /// An object that represents the coordinates of a location. @@ -158,4 +206,25 @@ class SemanticTagTypePersonNameComponents { /// The phonetic representation of the person’s name. @JsonKey(name: 'phoneticRepresentation') final String? phoneticRepresentation; + + SemanticTagTypePersonNameComponents copyWith({ + String? familyName, + String? givenName, + String? middleName, + String? namePrefix, + String? nameSuffix, + String? nickname, + String? phoneticRepresentation, + }) { + return SemanticTagTypePersonNameComponents( + familyName: familyName ?? this.familyName, + givenName: givenName ?? this.givenName, + middleName: middleName ?? this.middleName, + namePrefix: namePrefix ?? this.namePrefix, + nameSuffix: nameSuffix ?? this.nameSuffix, + nickname: nickname ?? this.nickname, + phoneticRepresentation: + phoneticRepresentation ?? this.phoneticRepresentation, + ); + } } diff --git a/passkit/lib/src/pkpass/semantics.dart b/passkit/lib/src/pkpass/semantics.dart index 54aa00e..dab21ea 100644 --- a/passkit/lib/src/pkpass/semantics.dart +++ b/passkit/lib/src/pkpass/semantics.dart @@ -406,6 +406,155 @@ class Semantics { /// An array of objects that represent the WiFi networks associated with the event; for example, the network name and password associated with a developer conference. Use this key for any type of pass. @JsonKey(name: 'wifiAccess') final List? wifiAccess; + + Semantics copyWith({ + String? airlineCode, + List? artistIDs, + String? awayTeamAbbreviation, + String? awayTeamLocation, + String? awayTeamName, + SemanticTagTypeCurrencyAmount? balance, + String? boardingGroup, + String? boardingSequenceNumber, + String? carNumber, + String? confirmationNumber, + DateTime? currentArrivalDate, + DateTime? currentBoardingDate, + DateTime? currentDepartureDate, + String? departureAirportCode, + String? departureAirportName, + String? departureGate, + SemanticTagTypeLocation? departureLocation, + String? departureLocationDescription, + String? departurePlatform, + String? departureStationName, + String? departureTerminal, + String? destinationAirportCode, + String? destinationAirportName, + String? destinationGate, + SemanticTagTypeLocation? destinationLocation, + String? destinationLocationDescription, + String? destinationPlatform, + String? destinationStationName, + String? destinationTerminal, + num? duration, + DateTime? eventEndDate, + String? eventName, + DateTime? eventStartDate, + EventType? eventType, + String? flightCode, + num? flightNumber, + String? genre, + String? homeTeamAbbreviation, + String? homeTeamLocation, + String? homeTeamName, + String? leagueAbbreviation, + String? leagueName, + String? membershipProgramName, + String? membershipProgramNumber, + DateTime? originalArrivalDate, + DateTime? originalBoardingDate, + DateTime? originalDepartureDate, + SemanticTagTypePersonNameComponents? passengerName, + List? performerNames, + String? priorityStatus, + List? seats, + String? securityScreening, + bool? silenceRequested, + String? sportName, + SemanticTagTypeCurrencyAmount? totalPrice, + String? transitProvider, + String? transitStatus, + String? transitStatusReason, + String? vehicleName, + String? vehicleNumber, + String? vehicleType, + String? venueEntrance, + SemanticTagTypeLocation? venueLocation, + String? venueName, + String? venuePhoneNumber, + String? venueRoom, + List? wifiAccess, + }) { + return Semantics( + airlineCode: airlineCode ?? this.airlineCode, + artistIDs: artistIDs ?? this.artistIDs, + awayTeamAbbreviation: awayTeamAbbreviation ?? this.awayTeamAbbreviation, + awayTeamLocation: awayTeamLocation ?? this.awayTeamLocation, + awayTeamName: awayTeamName ?? this.awayTeamName, + balance: balance ?? this.balance, + boardingGroup: boardingGroup ?? this.boardingGroup, + boardingSequenceNumber: + boardingSequenceNumber ?? this.boardingSequenceNumber, + carNumber: carNumber ?? this.carNumber, + confirmationNumber: confirmationNumber ?? this.confirmationNumber, + currentArrivalDate: currentArrivalDate ?? this.currentArrivalDate, + currentBoardingDate: currentBoardingDate ?? this.currentBoardingDate, + currentDepartureDate: currentDepartureDate ?? this.currentDepartureDate, + departureAirportCode: departureAirportCode ?? this.departureAirportCode, + departureAirportName: departureAirportName ?? this.departureAirportName, + departureGate: departureGate ?? this.departureGate, + departureLocation: departureLocation ?? this.departureLocation, + departureLocationDescription: + departureLocationDescription ?? this.departureLocationDescription, + departurePlatform: departurePlatform ?? this.departurePlatform, + departureStationName: departureStationName ?? this.departureStationName, + departureTerminal: departureTerminal ?? this.departureTerminal, + destinationAirportCode: + destinationAirportCode ?? this.destinationAirportCode, + destinationAirportName: + destinationAirportName ?? this.destinationAirportName, + destinationGate: destinationGate ?? this.destinationGate, + destinationLocation: destinationLocation ?? this.destinationLocation, + destinationLocationDescription: + destinationLocationDescription ?? this.destinationLocationDescription, + destinationPlatform: destinationPlatform ?? this.destinationPlatform, + destinationStationName: + destinationStationName ?? this.destinationStationName, + destinationTerminal: destinationTerminal ?? this.destinationTerminal, + duration: duration ?? this.duration, + eventEndDate: eventEndDate ?? this.eventEndDate, + eventName: eventName ?? this.eventName, + eventStartDate: eventStartDate ?? this.eventStartDate, + eventType: eventType ?? this.eventType, + flightCode: flightCode ?? this.flightCode, + flightNumber: flightNumber ?? this.flightNumber, + genre: genre ?? this.genre, + homeTeamAbbreviation: homeTeamAbbreviation ?? this.homeTeamAbbreviation, + homeTeamLocation: homeTeamLocation ?? this.homeTeamLocation, + homeTeamName: homeTeamName ?? this.homeTeamName, + leagueAbbreviation: leagueAbbreviation ?? this.leagueAbbreviation, + leagueName: leagueName ?? this.leagueName, + membershipProgramName: + membershipProgramName ?? this.membershipProgramName, + membershipProgramNumber: + membershipProgramNumber ?? this.membershipProgramNumber, + originalArrivalDate: originalArrivalDate ?? this.originalArrivalDate, + originalBoardingDate: originalBoardingDate ?? this.originalBoardingDate, + originalDepartureDate: + originalDepartureDate ?? this.originalDepartureDate, + passengerName: passengerName ?? this.passengerName, + performerNames: performerNames ?? this.performerNames, + priorityStatus: priorityStatus ?? this.priorityStatus, + seats: seats ?? this.seats, + securityScreening: securityScreening ?? this.securityScreening, + silenceRequested: silenceRequested ?? this.silenceRequested, + sportName: sportName ?? this.sportName, + totalPrice: totalPrice ?? this.totalPrice, + transitProvider: transitProvider ?? this.transitProvider, + transitStatus: transitStatus ?? this.transitStatus, + transitStatusReason: transitStatusReason ?? this.transitStatusReason, + vehicleName: vehicleName ?? this.vehicleName, + vehicleNumber: vehicleNumber ?? this.vehicleNumber, + vehicleType: vehicleType ?? this.vehicleType, + venueEntrance: venueEntrance ?? this.venueEntrance, + venueLocation: venueLocation ?? this.venueLocation, + venueName: venueName ?? this.venueName, + venuePhoneNumber: venuePhoneNumber ?? this.venuePhoneNumber, + venueRoom: venueRoom ?? this.venueRoom, + wifiAccess: wifiAccess ?? this.wifiAccess, + ); + } } enum EventType { diff --git a/passkit/lib/src/pkpass_webservice/passkit_web_client.dart b/passkit/lib/src/pkpass_webservice/passkit_web_client.dart index 0db3e24..d22e259 100644 --- a/passkit/lib/src/pkpass_webservice/passkit_web_client.dart +++ b/passkit/lib/src/pkpass_webservice/passkit_web_client.dart @@ -4,11 +4,7 @@ import 'dart:typed_data'; import 'package:http/http.dart'; import 'package:http_parser/http_parser.dart'; import 'package:passkit/passkit.dart'; - -/// Dart uses a special fast decoder when using a fused [Utf8Decoder] and [JsonDecoder]. -/// This speeds up decoding. -/// See https://github.com/dart-lang/sdk/blob/5b2ea0c7a227d91c691d2ff8cbbeb5f7f86afdb9/sdk/lib/_internal/vm/lib/convert_patch.dart#L40 -final _utf8JsonDecoder = const Utf8Decoder().fuse(const JsonDecoder()); +import 'package:passkit/src/utils.dart'; /// This class allows you to update a [PkPass] the latest version, if the pass /// allows it. @@ -233,8 +229,7 @@ class PassKitWebClient { return switch (response.statusCode) { 200 => () { - final responseJson = _utf8JsonDecoder.convert(response.bodyBytes) - as Map; + final responseJson = utf8JsonDecode(response.bodyBytes)!; return SerialNumbers( serialNumbers: responseJson['serialNumbers'] as List, lastUpdated: responseJson['lastUpdated'] as String, diff --git a/passkit/lib/src/strings_parser/naive_strings_file_parser.dart b/passkit/lib/src/strings/naive_strings_file_parser.dart similarity index 95% rename from passkit/lib/src/strings_parser/naive_strings_file_parser.dart rename to passkit/lib/src/strings/naive_strings_file_parser.dart index e2f27bc..5dfa999 100644 --- a/passkit/lib/src/strings_parser/naive_strings_file_parser.dart +++ b/passkit/lib/src/strings/naive_strings_file_parser.dart @@ -1,8 +1,9 @@ import 'dart:convert'; +import 'dart:typed_data'; /// Parses [content] to a [Map] which contains the /// key-value-pairs for translations. -Map parseStringsFile(List content) { +Map parseStringsFile(Uint8List content) { final string = _stringsFileDecoder.convert(content); return naiveStringsFileParser(string); } diff --git a/passkit/lib/src/strings/strings_writer.dart b/passkit/lib/src/strings/strings_writer.dart new file mode 100644 index 0000000..f5ef810 --- /dev/null +++ b/passkit/lib/src/strings/strings_writer.dart @@ -0,0 +1,15 @@ +/// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html +/// https://localizely.com/apple-strings-file/ +String toStringsFile(Map pairs) { + return pairs.entries.map(_encoder).join('\n'); +} + +String _encoder(MapEntry entry) { + final key = entry.key; + final value = entry.value + .replaceAll('\\', '\\\\') + .replaceAll('\n', '\\n') + .replaceAll('"', '\\"'); + + return '"$key" = "$value";'; +} diff --git a/passkit/lib/src/utils.dart b/passkit/lib/src/utils.dart new file mode 100644 index 0000000..ad5bfbc --- /dev/null +++ b/passkit/lib/src/utils.dart @@ -0,0 +1,16 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// Dart uses a special fast decoder when using a fused [Utf8Decoder] and [JsonDecoder]. +/// This speeds up decoding. +/// See https://github.com/dart-lang/sdk/blob/5b2ea0c7a227d91c691d2ff8cbbeb5f7f86afdb9/sdk/lib/_internal/vm/lib/convert_patch.dart#L40 +final _utf8JsonDecoder = const Utf8Decoder().fuse(const JsonDecoder()); + +Map? utf8JsonDecode(Uint8List data) => + _utf8JsonDecoder.convert(data) as Map?; + +/// Fast encoder for JSON +final _utf8JsonEncoder = JsonUtf8Encoder(); + +Uint8List utf8JsonEncode(Object data) => + Uint8List.fromList(_utf8JsonEncoder.convert(data)); diff --git a/passkit/pubspec.yaml b/passkit/pubspec.yaml index a011afb..c08e643 100644 --- a/passkit/pubspec.yaml +++ b/passkit/pubspec.yaml @@ -1,6 +1,6 @@ name: passkit -description: Pure Dart library which can read Apple's PKPass files. Works on servers too. -version: 0.0.6 +description: Pure Dart library which can create and read Apple's PkPass files. Works on servers and in apps. +version: 0.0.10 repository: https://github.com/ueman/passkit issue_tracker: https://github.com/ueman/passkit/issues topics: @@ -11,19 +11,21 @@ screenshots: path: assets/boarding_pass.webp environment: - sdk: ">=3.4.0 <4.0.0" + sdk: ">=3.3.0 <4.0.0" dependencies: - archive: ^3.6.0 + archive: ^3.4.0 collection: ^1.18.0 crypto: ^3.0.0 csslib: ^1.0.0 http: ^1.2.0 http_parser: ^4.0.0 - intl: ^0.19.0 - json_annotation: ^4.9.0 + image: ^4.1.1 + intl: ">=0.18.0 <0.20.0" # This makes it possible to use this library on older Flutter versions + json_annotation: ^4.8.0 meta: ^1.0.0 - pkcs7: ^1.0.3 + pkcs7: ^1.0.0 + pointycastle: ^3.7.0 dev_dependencies: build_runner: ^2.3.2 diff --git a/passkit/test/pkpass/create_pass_test.dart b/passkit/test/pkpass/create_pass_test.dart index d97c5e4..9233138 100644 --- a/passkit/test/pkpass/create_pass_test.dart +++ b/passkit/test/pkpass/create_pass_test.dart @@ -14,7 +14,10 @@ void main() { skipSignatureVerification: true, ); - final createdPass = og.write()!; + final createdPass = og.write( + certificatePem: null, + privateKeyPem: null, + )!; final copy = PkPass.fromBytes( Uint8List.fromList(createdPass), @@ -29,8 +32,8 @@ void main() { ); // pass.json file is not identical due to whitespace differences - final copyManifest = copy.manifest..remove('pass.json'); - final ogManifest = og.manifest..remove('pass.json'); + final copyManifest = copy.manifest?..remove('pass.json'); + final ogManifest = og.manifest?..remove('pass.json'); expect(copyManifest, ogManifest); }); diff --git a/passkit/test/pkpass/helpers.dart b/passkit/test/pkpass/helpers.dart index cb44ea5..493aae7 100644 --- a/passkit/test/pkpass/helpers.dart +++ b/passkit/test/pkpass/helpers.dart @@ -10,7 +10,6 @@ Uint8List loadSample(PassType type) { PassType.eventTicket => 'Event', PassType.storeCard => 'StoreCard', PassType.generic => 'Generic', - PassType.unknown => 'Generic', }; return File('test/sample_passes/$fileName.pkpass').readAsBytesSync(); } diff --git a/passkit/test/strings_file_parser_test.dart b/passkit/test/strings_file_parser_test.dart index 881f078..ed305c9 100644 --- a/passkit/test/strings_file_parser_test.dart +++ b/passkit/test/strings_file_parser_test.dart @@ -1,4 +1,4 @@ -import 'package:passkit/src/strings_parser/naive_strings_file_parser.dart'; +import 'package:passkit/src/strings/naive_strings_file_parser.dart'; import 'package:test/test.dart'; // Taken from https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html diff --git a/passkit_server/.gitignore b/passkit_server/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/passkit_server/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/passkit_server/CHANGELOG.md b/passkit_server/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/passkit_server/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/passkit_server/README.md b/passkit_server/README.md new file mode 100644 index 0000000..585c472 --- /dev/null +++ b/passkit_server/README.md @@ -0,0 +1,120 @@ +# PassKit Server + +[![pub package](https://img.shields.io/pub/v/passkit_server.svg)](https://pub.dev/packages/passkit_server) +[![likes](https://img.shields.io/pub/likes/passkit_server)](https://pub.dev/packages/passkit_server/score) +[![popularity](https://img.shields.io/pub/popularity/passkit_server)](https://pub.dev/packages/passkit_server/score) +[![pub points](https://img.shields.io/pub/points/passkit_server)](https://pub.dev/packages/passkit_server/score) + + +[![Twitter Follow](https://img.shields.io/twitter/follow/ue_man?style=social)](https://twitter.com/ue_man) +[![GitHub followers](https://img.shields.io/github/followers/ueman?style=social)](https://github.com/ueman) + +------- + +PassKit allows you to work with Apple's PkPass and Order files. This is a Dart library, which allows you to integrate the PassKit enpoint in your shelf (and potentially dart_frog) application. + +In order to show PassKit and Order files in Flutter, use the [`passkit_ui`](https://pub.dev/packages/passkit_ui) package, which includes ready made widgets. + +Want to work with Apple's native PassKit APIs in Flutter? Consider using [`apple_passkit`](https://pub.dev/packages/apple_passkit). + +Please read through the [Apple Documentation](https://developer.apple.com/documentation/walletpasses/adding-a-web-service-to-update-passes) for the server implementation first. + +A brief example looks roughly like this. Unfortunately, it's not possible to move more logic into +the library, since it depends too much on your application logic. + +```dart +import 'dart:async'; + +import 'package:passkit/src/pkpass/pkpass.dart'; +import 'package:passkit_server/passkit_server.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_router/shelf_router.dart'; + +Future main() async { + var app = Router(); + + app.addPassKitServer(CustomPassKitBackend()); + + app.get('/hello', (Request request) { + return Response.ok('hello-world'); + }); + + var server = await io.serve(app.call, 'localhost', 8888); +} + +class CustomPassKitBackend extends PassKitBackend { + @override + Future getLatestPassFor(String identifier, String serial) async { + print('getLatestPassFor($identifier, $serial)'); + return null; + } + + @override + FutureOr isKnownDeviceId(String deviceId) { + print('isKnownDeviceId($deviceId)'); + return true; + } + + @override + FutureOr isValidAuthToken(String serial, String authToken) { + print('isValidAuthToken($serial, $authToken)'); + return true; + } + + @override + Future logMessage(Map message) async { + print('logMessage($message)'); + } + + @override + Future returnUpdatablePasses( + String deviceId, + String typeId, + String? lastTag, + ) async { + print('returnUpdatablePasses($deviceId, $typeId, $lastTag)'); + return null; + } + + @override + Future startSendingPushNotificationsFor( + String deviceId, + String passTypeId, + String serialNumber, + String pushToken, + ) async { + print( + 'startSendingPushNotificationsFor($deviceId, $passTypeId, $serialNumber, $pushToken)', + ); + return NotificationRegistrationReponse.created; + } + + @override + Future stopSendingPushNotificationsFor( + String deviceId, + String passTypeId, + String serialNumber, + ) async { + print( + 'stopSendingPushNotificationsFor($deviceId, $passTypeId, $serialNumber)', + ); + return true; + } +} +``` + +# How to structure persistence for the server + +Apple recommend to build the persistence this way: + +Updating passes requires storing information for the registered passes and for their associated devices. One way you can store these details is to use a traditional relational database with two entities – devices and passes – and one relationship, registrations. The three tables are: + +#### Device table +Contains the devices that contain updatable passes. Information for a device includes the device library identifier and the push token that your server uses to send update notifications. + +#### Pass table +Contains the updatable passes. Information for a pass includes the pass type identifier, serial number, and a last-update tag. You define the contents of this tag and use it to track when you last updated a pass. The table can also include other data that you require to generate an updated pass. + +#### Registration table +Contains the relationships between passes and devices. Use this table to find the devices registered for a pass, and to find all the registered passes for a device. Both relationships are many-to-many. diff --git a/passkit_server/analysis_options.yaml b/passkit_server/analysis_options.yaml new file mode 100644 index 0000000..bcd8401 --- /dev/null +++ b/passkit_server/analysis_options.yaml @@ -0,0 +1,27 @@ +include: package:lints/recommended.yaml + +linter: + rules: + prefer_single_quotes: true + unawaited_futures: true + sort_constructors_first: true + use_key_in_widget_constructors: true + use_super_parameters: true + use_colored_box: true + use_decorated_box: true + no_leading_underscores_for_local_identifiers: true + require_trailing_commas: true + flutter_style_todos: true + sort_pub_dependencies: true + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + missing_required_param: error + missing_return: error + todo: ignore + exclude: + - "**.g.dart" diff --git a/passkit_server/example/passkit_server_example.dart b/passkit_server/example/passkit_server_example.dart new file mode 100644 index 0000000..261b2d0 --- /dev/null +++ b/passkit_server/example/passkit_server_example.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:passkit/src/pkpass/pkpass.dart'; +import 'package:passkit_server/passkit_server.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_router/shelf_router.dart'; + +Future main() async { + var app = Router(); + + app.addPassKitServer(CustomPassKitBackend()); + + app.get('/hello', (Request request) { + return Response.ok('hello-world'); + }); + + // ignore: unused_local_variable + var server = await io.serve(app.call, 'localhost', 8888); +} + +class CustomPassKitBackend extends PassKitBackend { + @override + Future getLatestPassFor(String identifier, String serial) async { + print('getLatestPassFor($identifier, $serial)'); + return null; + } + + @override + FutureOr isKnownDeviceId(String deviceId) { + print('isKnownDeviceId($deviceId)'); + return true; + } + + @override + FutureOr isValidAuthToken(String serial, String authToken) { + print('isValidAuthToken($serial, $authToken)'); + return true; + } + + @override + Future logMessage(Map message) async { + print('logMessage($message)'); + } + + @override + Future returnUpdatablePasses( + String deviceId, + String typeId, + String? lastTag, + ) async { + print('returnUpdatablePasses($deviceId, $typeId, $lastTag)'); + return null; + } + + @override + Future startSendingPushNotificationsFor( + String deviceId, + String passTypeId, + String serialNumber, + String pushToken, + ) async { + print( + 'startSendingPushNotificationsFor($deviceId, $passTypeId, $serialNumber, $pushToken)', + ); + return NotificationRegistrationReponse.created; + } + + @override + Future stopSendingPushNotificationsFor( + String deviceId, + String passTypeId, + String serialNumber, + ) async { + print( + 'stopSendingPushNotificationsFor($deviceId, $passTypeId, $serialNumber)', + ); + return true; + } +} diff --git a/passkit_server/lib/passkit_server.dart b/passkit_server/lib/passkit_server.dart new file mode 100644 index 0000000..16146b5 --- /dev/null +++ b/passkit_server/lib/passkit_server.dart @@ -0,0 +1,2 @@ +export 'src/passkit_backend.dart'; +export 'src/passkit_server.dart' show PasskitServerExtension; diff --git a/passkit_server/lib/src/passkit_backend.dart b/passkit_server/lib/src/passkit_backend.dart new file mode 100644 index 0000000..a65ac00 --- /dev/null +++ b/passkit_server/lib/src/passkit_backend.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:passkit/passkit.dart'; + +abstract class PassKitBackend { + /// Saves JSON that gets send to `/v1/log` + Future logMessage(Map message); + + /// Return `null` if there are no updatable passes. + /// Otherwise return an instance of it. + /// [lastTag], if non-null, describes the last point in time where the wallet + /// app made a request to get updateable passes. + Future returnUpdatablePasses( + String deviceId, + String typeId, + String? lastTag, + ); + + /// Must return the latest pass for the given [identifier] and [serial] + Future getLatestPassFor( + String identifier, + String serial, + ); + + /// Start sending push notifications for the given parameters. + /// Consider that a user can have added the same pass to multiple devices. + Future startSendingPushNotificationsFor( + String deviceId, + String passTypeId, + String serialNumber, + String pushToken, + ); + + /// Stop sending push notifications for the given parameters. + /// Consider that a user can have added the same pass to multiple devices. + Future stopSendingPushNotificationsFor( + String deviceId, + String passTypeId, + String serialNumber, + ); + + /// Should return true if the [serial] and [authToken] match each other + /// and are valid on their own. Otherwise it should return false. + FutureOr isValidAuthToken(String serial, String authToken); + + /// Should return true if the [deviceId] is known, otherwise it should return + /// false. + FutureOr isKnownDeviceId(String deviceId); +} + +class UpdatablePassResponse { + UpdatablePassResponse({this.tag, required this.ids}) : assert(ids.isNotEmpty); + + final String? tag; + final List ids; + + Map toJson() { + return { + 'lastUpdated': tag, + 'serialNumbers': ids, + }; + } +} + +enum NotificationRegistrationReponse { + created, + existing, +} diff --git a/passkit_server/lib/src/passkit_server.dart b/passkit_server/lib/src/passkit_server.dart new file mode 100644 index 0000000..ab52059 --- /dev/null +++ b/passkit_server/lib/src/passkit_server.dart @@ -0,0 +1,230 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:passkit_server/src/passkit_backend.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +extension PasskitServerExtension on Router { + void addPassKitServer(PassKitBackend backend) { + post( + '/v1/devices//registrations//', + setupNotifications(backend), + ); + get( + '/v1/devices//registrations/', + getListOfUpdatablePasses(backend), + ); + delete( + '/v1/devices//registrations//', + stopNotifications(backend), + ); + get('/v1/passes//', getLatestVersion(backend)); + post('/v1/log', logMessages(backend)); + } +} + +/// Pass delivery +/// +/// GET /v1/passes// +/// Header: Authorization: ApplePass +/// +/// server response: +/// --> if auth token is correct: 200, with pass data payload +/// --> if auth token is incorrect: 401 +Function getLatestVersion(PassKitBackend backend) { + return (Request request, String identifier, String serial) async { + final response = await backend.validateAuthToken(request, serial); + if (response != null) { + return response; + } + + final pass = await backend.getLatestPassFor(identifier, serial); + + if (pass == null) { + return Response.unauthorized(null); + } + + return Response.ok( + pass.sourceData, + headers: { + 'Content-type': 'application/vnd.apple.pkpass', + 'Content-disposition': 'attachment; filename=pass.pkpass', + }, + ); + }; +} + +/// Logging/Debugging from the device +/// +/// log an error or unexpected server behavior, to help with server debugging +/// POST /v1/log +/// JSON payload: { "description" : } +/// +/// server response: 200 +Function logMessages(PassKitBackend backend) { + return (Request request) async { + final content = await request.readAsString(); + // There's no need to wait for the log message to be written, instead return + // a 200 status code response right away + unawaited( + backend.logMessage(jsonDecode(content) as Map), + ); + return Response.ok(null); + }; +} + +/// Registration +/// register a device to receive push notifications for a pass +/// +/// POST /v1/devices//registrations// +/// Header: Authorization: ApplePass +/// JSON payload: +/// ```json +/// { "pushToken" : } +/// ``` +/// +/// Params definition +/// [deviceId] : the device's identifier +/// [passTypeId] : the bundle identifier for a class of passes, sometimes refered +/// to as the pass topic, e.g. pass.com.apple.backtoschoolgift, +/// registered with WWDR +/// [serialNumber] : the pass' serial number +/// `pushToken` (from the [request]): the value needed for Apple Push Notification service +/// +/// server action: if the authentication token is correct, associate the given +/// push token and device identifier with this pass +/// server response: +/// --> if registration succeeded: 201 +/// --> if this serial number was already registered for this device: 304 +/// --> if not authorized: 401 +Function setupNotifications(PassKitBackend backend) { + return ( + Request request, + String deviceId, + String passTypeId, + String serialNumber, + ) async { + final response = await backend.validateAuthToken(request, serialNumber); + if (response != null) { + return response; + } + final body = await request.readAsString(); + final bodyJson = jsonDecode(body) as Map; + final pushToken = bodyJson['pushToken'] as String?; + if (pushToken == null) { + // TODO(anyone): include more information in debug mode? + return Response.badRequest(); + } + + final notificationRegistrationReponse = + await backend.startSendingPushNotificationsFor( + deviceId, + passTypeId, + serialNumber, + pushToken, + ); + + return switch (notificationRegistrationReponse) { + NotificationRegistrationReponse.created => Response(201), + NotificationRegistrationReponse.existing => Response.ok(null), + }; + }; +} + +/// Unregister +/// +/// unregister a device to receive push notifications for a pass +/// +/// DELETE /v1/devices//registrations// +/// Header: Authorization: ApplePass +/// +/// server action: if the authentication token is correct, disassociate the +/// device from this pass +/// server response: +/// --> if disassociation succeeded: 200 +/// --> if not authorized: 401 +Function stopNotifications(PassKitBackend backend) { + return ( + Request request, + String deviceId, + String passTypeId, + String serialNumber, + ) async { + final response = await backend.validateAuthToken(request, serialNumber); + if (response != null) { + return response; + } + final success = await backend.stopSendingPushNotificationsFor( + deviceId, + passTypeId, + serialNumber, + ); + if (success) { + return Response.ok(null); + } + + // TODO(anyone): Is this correct? + return Response.internalServerError(); + }; +} + +/// Updatable passes +/// +/// Get all serial numbers associated with a device for passes that need an update +/// Optionally with a query limiter to scope the last update since +/// +/// GET /v1/devices//registrations/ +/// GET /v1/devices//registrations/?passesUpdatedSince= +/// +/// server action: figure out which passes associated with this device have been modified since the supplied tag (if no tag provided, all associated serial #s) +/// server response: +/// --> if there are matching passes: 200, with JSON payload: { "lastUpdated" : , "serialNumbers" : [ ] } +/// --> if there are no matching passes: 204 +/// --> if unknown device identifier: 404 +Function getListOfUpdatablePasses(PassKitBackend backend) { + return (Request request, String deviceId, String typeId) async { + final isKnownDeviceIdentifier = await backend.isKnownDeviceId(deviceId); + if (!isKnownDeviceIdentifier) { + return Response.notFound(null); + } + final lastTag = request.url.queryParameters['passesUpdatedSince']; + + final updateablePasses = + await backend.returnUpdatablePasses(deviceId, typeId, lastTag); + + if (updateablePasses == null) { + return Response(204); + } + + return Response.ok(jsonEncode(updateablePasses.toJson())); + }; +} + +extension on Request { + String? getApplePassToken() { + var header = headers['Authorization']; + if (header?.startsWith('ApplePass ') == true) { + var token = header?.split(' ').lastOrNull; + return token; + } + + return null; + } +} + +extension on PassKitBackend { + Future validateAuthToken(Request request, String serial) async { + var token = request.getApplePassToken(); + + if (token == null) { + return Response.unauthorized(null); + } + + final isValidToken = await isValidAuthToken(serial, token); + if (!isValidToken) { + return Response.unauthorized(null); + } + return null; + } +} diff --git a/passkit_server/lib/src/utils.dart b/passkit_server/lib/src/utils.dart new file mode 100644 index 0000000..40f7681 --- /dev/null +++ b/passkit_server/lib/src/utils.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// Dart uses a special fast decoder when using a fused [Utf8Decoder] and [JsonDecoder]. +/// This speeds up decoding. +/// See https://github.com/dart-lang/sdk/blob/5b2ea0c7a227d91c691d2ff8cbbeb5f7f86afdb9/sdk/lib/_internal/vm/lib/convert_patch.dart#L40 +final _utf8JsonDecoder = const Utf8Decoder().fuse(const JsonDecoder()); + +Map? utf8JsonDecode(Uint8List data) => + _utf8JsonDecoder.convert(data) as Map?; + +/// Fast encoder for JSON +final _utf8JsonEncoder = JsonUtf8Encoder(); + +Uint8List utf8JsonEncode(Object data) => + Uint8List.fromList(_utf8JsonEncoder.convert(data)); + +const kdebugMode = bool.fromEnvironment('dart.vm.product'); diff --git a/passkit_server/pubspec.yaml b/passkit_server/pubspec.yaml new file mode 100644 index 0000000..11bba5b --- /dev/null +++ b/passkit_server/pubspec.yaml @@ -0,0 +1,20 @@ +name: passkit_server +description: Easily add Apple's PassKit API endpoints to your server. +version: 0.0.1 +repository: https://github.com/ueman/passkit +topics: + - passkit + - pkpass + - backend + +environment: + sdk: ^3.4.0 + +dependencies: + passkit: ^0.0.7 + shelf: ^1.4.0 + shelf_router: ^1.1.0 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.24.0 diff --git a/passkit_ui/CHANGELOG.md b/passkit_ui/CHANGELOG.md index 582df06..3cdb3de 100644 --- a/passkit_ui/CHANGELOG.md +++ b/passkit_ui/CHANGELOG.md @@ -1,6 +1,7 @@ ## 0.0.5 - Add ability to create an image from a PkPass +- Require `passkit` v0.0.8 ## 0.0.4 diff --git a/passkit_ui/example/pubspec.lock b/passkit_ui/example/pubspec.lock index 8aaeb77..b4ec224 100644 --- a/passkit_ui/example/pubspec.lock +++ b/passkit_ui/example/pubspec.lock @@ -9,6 +9,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "2ca377ad4d677bbadca278e0ba4da4e057b80a10b927bfc8f7d8bda8fe2ceb75" + url: "https://pub.dev" + source: hosted + version: "1.5.4" async: dependency: transitive description: @@ -89,14 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: + encrypt: dependency: transitive description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "5.0.3" fake_async: dependency: transitive description: @@ -167,18 +183,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -207,25 +223,25 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" passkit: dependency: "direct main" description: path: "../../passkit" relative: true source: path - version: "0.0.6" + version: "0.0.7" passkit_ui: dependency: "direct main" description: @@ -330,10 +346,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" typed_data: dependency: transitive description: @@ -354,10 +370,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" web: dependency: transitive description: diff --git a/passkit_ui/lib/src/pk_pass_widget.dart b/passkit_ui/lib/src/pk_pass_widget.dart index 2de2407..13c75af 100644 --- a/passkit_ui/lib/src/pk_pass_widget.dart +++ b/passkit_ui/lib/src/pk_pass_widget.dart @@ -51,7 +51,6 @@ class PkPassWidget extends StatelessWidget { PassType.eventTicket => EventTicket(pass: pass), PassType.storeCard => StoreCard(pass: pass), PassType.generic => Generic(pass: pass), - PassType.unknown => Generic(pass: pass), }, ), ); diff --git a/passkit_ui/lib/src/widgets/strip_image.dart b/passkit_ui/lib/src/widgets/strip_image.dart index 07e6d0c..85879d7 100644 --- a/passkit_ui/lib/src/widgets/strip_image.dart +++ b/passkit_ui/lib/src/widgets/strip_image.dart @@ -25,7 +25,6 @@ class StripImage extends StatelessWidget { PassType.eventTicket => const Size(320, 98), PassType.storeCard => const Size(320, 144), PassType.generic => const Size(320, 123), - PassType.unknown => const Size(320, 123), }; if (image == null) { return SizedBox( diff --git a/passkit_ui/pubspec.yaml b/passkit_ui/pubspec.yaml index 3038eb7..b6958bd 100644 --- a/passkit_ui/pubspec.yaml +++ b/passkit_ui/pubspec.yaml @@ -15,16 +15,16 @@ environment: flutter: ">=3.22.2" dependencies: - barcode: ^2.2.0 + barcode: ^2.0.0 barcode_widget: ^2.0.0 collection: ^1.18.0 csslib: ^1.0.0 cupertino_icons: ^1.0.8 flutter: sdk: flutter - intl: ^0.19.0 + intl: ">=0.18.0 <0.20.0" meta: ^1.0.0 - passkit: ^0.0.5 + passkit: ^0.0.8 dev_dependencies: flutter_lints: ^4.0.0