From 50db67cbbfeb0be2044892bd410b0f1482a615c2 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Mon, 1 Jul 2024 11:19:41 +0200 Subject: [PATCH] Test order content generation --- Sources/Orders/OrdersDelegate.swift | 120 ++++++++++++++++++ Sources/Orders/OrdersService.swift | 38 ++++++ Sources/Orders/OrdersServiceCustom.swift | 147 +++++++++++++++++++++++ Sources/Passes/PassesDelegate.swift | 2 +- 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 Sources/Orders/OrdersDelegate.swift create mode 100644 Sources/Orders/OrdersService.swift create mode 100644 Sources/Orders/OrdersServiceCustom.swift diff --git a/Sources/Orders/OrdersDelegate.swift b/Sources/Orders/OrdersDelegate.swift new file mode 100644 index 0000000..87a784f --- /dev/null +++ b/Sources/Orders/OrdersDelegate.swift @@ -0,0 +1,120 @@ +// +// OrdersDelegate.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +import Foundation +import FluentKit + +/// The delegate which is responsible for generating the order files. +public protocol OrdersDelegate: AnyObject, Sendable { + /// Should return a `URL` which points to the template data for the order. + /// + /// The URL should point to a directory containing all the images and localizations for the generated `.order` archive but should *not* contain any of these items: + /// - `manifest.json` + /// - `order.json` + /// - `signature` + /// + /// - Parameters: + /// - for: The order data from the SQL server. + /// - db: The SQL database to query against. + /// + /// - Returns: A `URL` which points to the template data for the order. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. + func template(for: O, db: any Database) async throws -> URL + + /// Generates the SSL `signature` file. + /// + /// If you need to implement custom S/Mime signing you can use this + /// method to do so. You must generate a detached DER signature of the `manifest.json` file. + /// + /// - Parameter root: The location of the `manifest.json` and where to write the `signature` to. + /// - Returns: Return `true` if you generated a custom `signature`, otherwise `false`. + func generateSignatureFile(in root: URL) -> Bool + + /// Encode the order into JSON. + /// + /// This method should generate the entire order JSON. You are provided with + /// the order data from the SQL database and you should return a properly + /// formatted order file encoding. + /// + /// - Parameters: + /// - order: The order data from the SQL server + /// - db: The SQL database to query against. + /// - encoder: The `JSONEncoder` which you should use. + /// - Returns: The encoded order JSON data. + /// + /// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys. + func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data + + /// Should return a `URL` which points to the template data for the order. + /// + /// The URL should point to a directory containing the files specified by these keys: + /// - `wwdrCertificate` + /// - `pemCertificate` + /// - `pemPrivateKey` + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` initializer! + var sslSigningFilesDirectory: URL { get } + + /// The location of the `openssl` command as a file URL. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. + var sslBinary: URL { get } + + /// The full path to the `zip` command as a file URL. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. + var zipBinary: URL { get } + + /// The name of Apple's WWDR.pem certificate as contained in `sslSigningFiles` path. + /// + /// Defaults to `WWDR.pem` + var wwdrCertificate: String { get } + + /// The name of the PEM Certificate for signing the order as contained in `sslSigningFiles` path. + /// + /// Defaults to `ordercertificate.pem` + var pemCertificate: String { get } + + /// The name of the PEM Certificate's private key for signing the order as contained in `sslSigningFiles` path. + /// + /// Defaults to `orderkey.pem` + var pemPrivateKey: String { get } + + /// The password to the private key file. + var pemPrivateKeyPassword: String? { get } +} + +public extension OrdersDelegate { + var wwdrCertificate: String { + get { return "WWDR.pem" } + } + + var pemCertificate: String { + get { return "ordercertificate.pem" } + } + + var pemPrivateKey: String { + get { return "orderkey.pem" } + } + + var pemPrivateKeyPassword: String? { + get { return nil } + } + + var sslBinary: URL { + get { return URL(fileURLWithPath: "/usr/bin/openssl") } + } + + var zipBinary: URL { + get { return URL(fileURLWithPath: "/usr/bin/zip") } + } + + func generateSignatureFile(in root: URL) -> Bool { + return false + } +} \ No newline at end of file diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift new file mode 100644 index 0000000..b711829 --- /dev/null +++ b/Sources/Orders/OrdersService.swift @@ -0,0 +1,38 @@ +// +// OrdersService.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +import Vapor +import FluentKit + +/// The main class that handles Wallet orders. +public final class OrdersService: Sendable { + private let service: OrdersServiceCustom + + public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { + service = .init(app: app, delegate: delegate, logger: logger) + } + + /// Generates the order content bundle for a given order. + /// + /// - Parameters: + /// - order: The order to generate the content for. + /// - db: The `Database` to use. + /// - Returns: The generated order content. + public func generatePassContent(for order: Order, on db: any Database) async throws -> Data { + try await service.generateOrderContent(for: order, on: db) + } + + /// Adds the migrations for Wallet orders models. + /// + /// - Parameter migrations: The `Migrations` object to add the migrations to. + public static func register(migrations: Migrations) { + migrations.add(Order()) + migrations.add(OrdersDevice()) + migrations.add(OrdersRegistration()) + migrations.add(OrdersErrorLog()) + } +} \ No newline at end of file diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift new file mode 100644 index 0000000..68f19ed --- /dev/null +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -0,0 +1,147 @@ +// +// OrdersServiceCustom.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +import Vapor +import APNS +import VaporAPNS +@preconcurrency import APNSCore +import Fluent +import NIOSSL +import PassKit + +/// Class to handle `OrdersService`. +/// +/// The generics should be passed in this order: +/// - Order Type +/// - Device Type +/// - Registration Type +/// - Error Log Type +public final class OrdersServiceCustom: Sendable where O == R.OrderType, D == R.DeviceType { + public unowned let delegate: any OrdersDelegate + private unowned let app: Application + + private let v1: FakeSendable + private let logger: Logger? + + public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { + self.delegate = delegate + self.logger = logger + self.app = app + + v1 = FakeSendable(value: app.grouped("api", "orders", "v1")) + } +} + +// MARK: - order file generation +extension OrdersServiceCustom { + private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws { + var manifest: [String: String] = [:] + + let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath()) + try paths.forEach { relativePath in + let file = URL(fileURLWithPath: relativePath, relativeTo: root) + guard !file.hasDirectoryPath else { + return + } + + let data = try Data(contentsOf: file) + let hash = SHA256.hash(data: data) + manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() + } + + let encoded = try encoder.encode(manifest) + try encoded.write(to: root.appendingPathComponent("manifest.json")) + } + + private func generateSignatureFile(in root: URL) throws { + if delegate.generateSignatureFile(in: root) { + // If the caller's delegate generated a file we don't have to do it. + return + } + + let sslBinary = delegate.sslBinary + + guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + throw OrdersError.opensslBinaryMissing + } + + let proc = Process() + proc.currentDirectoryURL = delegate.sslSigningFilesDirectory + proc.executableURL = sslBinary + + proc.arguments = [ + "smime", "-binary", "-sign", + "-certfile", delegate.wwdrCertificate, + "-signer", delegate.pemCertificate, + "-inkey", delegate.pemPrivateKey, + "-in", root.appendingPathComponent("manifest.json").unixPath(), + "-out", root.appendingPathComponent("signature").unixPath(), + "-outform", "DER" + ] + + if let pwd = delegate.pemPrivateKeyPassword { + proc.arguments!.append(contentsOf: ["-passin", "pass:\(pwd)"]) + } + + try proc.run() + + proc.waitUntilExit() + } + + private func zip(directory: URL, to: URL) throws { + let zipBinary = delegate.zipBinary + guard FileManager.default.fileExists(atPath: zipBinary.unixPath()) else { + throw OrdersError.zipBinaryMissing + } + + let proc = Process() + proc.currentDirectoryURL = directory + proc.executableURL = zipBinary + + proc.arguments = [ to.unixPath(), "-r", "-q", "." ] + + try proc.run() + proc.waitUntilExit() + } + + public func generateOrderContent(for order: O, on db: any Database) async throws -> Data { + let tmp = FileManager.default.temporaryDirectory + let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) + let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip") + let encoder = JSONEncoder() + + let src = try await delegate.template(for: order, db: db) + guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + throw OrdersError.templateNotDirectory + } + + let encoded = try await self.delegate.encode(order: order, db: db, encoder: encoder) + + do { + try FileManager.default.copyItem(at: src, to: root) + + defer { + _ = try? FileManager.default.removeItem(at: root) + } + + try encoded.write(to: root.appendingPathComponent("order.json")) + + try Self.generateManifestFile(using: encoder, in: root) + try self.generateSignatureFile(in: root) + + try self.zip(directory: root, to: zipFile) + + defer { + _ = try? FileManager.default.removeItem(at: zipFile) + } + + return try Data(contentsOf: zipFile) + } catch { + throw error + } + } +} \ No newline at end of file diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index 8170e51..ca57ec5 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -39,7 +39,7 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - `signature` /// /// - Parameters: - /// - pass: The pass data from the SQL server. + /// - for: The pass data from the SQL server. /// - db: The SQL database to query against. /// /// - Returns: A `URL` which points to the template data for the pass.