diff --git a/Package.swift b/Package.swift index 2c86379..eea937c 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "swift-wallet", platforms: [ - .macOS(.v12) + .macOS(.v13) ], products: [ .library(name: "WalletPasses", targets: ["WalletPasses"]), @@ -12,14 +12,14 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-certificates.git", from: "1.6.1"), - .package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.4"), + .package(url: "https://github.com/adam-fowler/swift-zip-archive.git", from: "0.4.1"), ], targets: [ .target( name: "WalletPasses", dependencies: [ .product(name: "X509", package: "swift-certificates"), - .product(name: "Zip", package: "zip"), + .product(name: "ZipArchive", package: "swift-zip-archive"), ], swiftSettings: swiftSettings ), @@ -37,7 +37,7 @@ let package = Package( name: "WalletOrders", dependencies: [ .product(name: "X509", package: "swift-certificates"), - .product(name: "Zip", package: "zip"), + .product(name: "ZipArchive", package: "swift-zip-archive"), ], swiftSettings: swiftSettings ), diff --git a/Sources/WalletOrders/OrderBuilder.swift b/Sources/WalletOrders/OrderBuilder.swift index aadb50e..e5458dc 100644 --- a/Sources/WalletOrders/OrderBuilder.swift +++ b/Sources/WalletOrders/OrderBuilder.swift @@ -1,9 +1,11 @@ import Crypto import Foundation @_spi(CMS) import X509 -import Zip +import ZipArchive -/// A tool that generates pass content bundles. +/// A tool that generates order content bundles. +/// +/// > Warning: You can only sign orders with the same order type identifier of the certificates used to initialize the ``OrderBuilder``. public struct OrderBuilder: Sendable { private let pemWWDRCertificate: String private let pemCertificate: String @@ -37,25 +39,25 @@ public struct OrderBuilder: Sendable { self.pemCertificate = pemCertificate self.pemPrivateKey = pemPrivateKey self.pemPrivateKeyPassword = pemPrivateKeyPassword - self.openSSLURL = URL(fileURLWithPath: openSSLPath) + self.openSSLURL = URL(filePath: openSSLPath) } private func signature(for manifest: Data) throws -> Data { // Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that. if let pemPrivateKeyPassword { - guard FileManager.default.fileExists(atPath: self.openSSLURL.path) else { + guard FileManager.default.fileExists(atPath: self.openSSLURL.path()) else { throw WalletOrdersError.noOpenSSLExecutable } - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } - let manifestURL = tempDir.appendingPathComponent(Self.manifestFileName) - let wwdrURL = tempDir.appendingPathComponent("wwdr.pem") - let certificateURL = tempDir.appendingPathComponent("certificate.pem") - let privateKeyURL = tempDir.appendingPathComponent("private.pem") - let signatureURL = tempDir.appendingPathComponent(Self.signatureFileName) + let manifestURL = tempDir.appending(path: Self.manifestFileName) + let wwdrURL = tempDir.appending(path: "wwdr.pem") + let certificateURL = tempDir.appending(path: "certificate.pem") + let privateKeyURL = tempDir.appending(path: "private.pem") + let signatureURL = tempDir.appending(path: Self.signatureFileName) try manifest.write(to: manifestURL) try self.pemWWDRCertificate.write(to: wwdrURL, atomically: true, encoding: .utf8) @@ -67,11 +69,11 @@ public struct OrderBuilder: Sendable { process.executableURL = self.openSSLURL process.arguments = [ "smime", "-binary", "-sign", - "-certfile", wwdrURL.path, - "-signer", certificateURL.path, - "-inkey", privateKeyURL.path, - "-in", manifestURL.path, - "-out", signatureURL.path, + "-certfile", wwdrURL.path(), + "-signer", certificateURL.path(), + "-inkey", privateKeyURL.path(), + "-in", manifestURL.path(), + "-out", signatureURL.path(), "-outform", "DER", "-passin", "pass:\(pemPrivateKeyPassword)", ] @@ -105,51 +107,43 @@ public struct OrderBuilder: Sendable { order: some OrderJSON.Properties, sourceFilesDirectoryPath: String ) throws -> Data { - let filesDirectory = URL(fileURLWithPath: sourceFilesDirectoryPath, isDirectory: true) - guard - (try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - else { + let filesDirectory = URL(filePath: sourceFilesDirectoryPath, directoryHint: .isDirectory) + guard (try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { throw WalletOrdersError.noSourceFiles } - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.copyItem(at: filesDirectory, to: tempDir) - defer { try? FileManager.default.removeItem(at: tempDir) } - - var archiveFiles: [ArchiveFile] = [] + var archiveFiles: [String: Data] = [:] + var manifestJSON: [String: String] = [:] let orderJSON = try self.encoder.encode(order) - try orderJSON.write(to: tempDir.appendingPathComponent("order.json")) - archiveFiles.append(ArchiveFile(filename: "order.json", data: orderJSON)) - - let sourceFilesPaths = try FileManager.default.subpathsOfDirectory(atPath: tempDir.path) - - var manifestJSON: [String: String] = [:] + archiveFiles["order.json"] = orderJSON + manifestJSON["order.json"] = orderJSON.manifestHash + let sourceFilesPaths = try FileManager.default.subpathsOfDirectory(atPath: filesDirectory.path()) for relativePath in sourceFilesPaths { - let fileURL = URL(fileURLWithPath: relativePath, relativeTo: tempDir) - - guard !fileURL.hasDirectoryPath else { - continue - } - - guard !(fileURL.lastPathComponent == ".gitkeep" || fileURL.lastPathComponent == ".DS_Store") else { - continue - } + let fileURL = URL(filePath: relativePath, directoryHint: .checkFileSystem, relativeTo: filesDirectory) + guard !fileURL.hasDirectoryPath else { continue } + if fileURL.lastPathComponent == ".gitkeep" || fileURL.lastPathComponent == ".DS_Store" { continue } let fileData = try Data(contentsOf: fileURL) - - archiveFiles.append(ArchiveFile(filename: relativePath, data: fileData)) - - manifestJSON[relativePath] = SHA256.hash(data: fileData).map { "0\(String($0, radix: 16))".suffix(2) }.joined() + archiveFiles[relativePath] = fileData + manifestJSON[relativePath] = fileData.manifestHash } let manifestData = try self.encoder.encode(manifestJSON) - archiveFiles.append(ArchiveFile(filename: Self.manifestFileName, data: manifestData)) - try archiveFiles.append(ArchiveFile(filename: Self.signatureFileName, data: self.signature(for: manifestData))) + archiveFiles[Self.manifestFileName] = manifestData + try archiveFiles[Self.signatureFileName] = self.signature(for: manifestData) + + let writer = ZipArchiveWriter() + for (filename, contents) in archiveFiles { + try writer.writeFile(filename: filename, contents: Array(contents)) + } + return try Data(writer.finalizeBuffer()) + } +} - let zipFile = tempDir.appendingPathComponent("\(UUID().uuidString).order") - try Zip.zipData(archiveFiles: archiveFiles, zipFilePath: zipFile) - return try Data(contentsOf: zipFile) +extension Data { + fileprivate var manifestHash: String { + SHA256.hash(data: self).map { "0\(String($0, radix: 16))".suffix(2) }.joined() } } diff --git a/Sources/WalletPasses/PassBuilder.swift b/Sources/WalletPasses/PassBuilder.swift index c78744d..386abd6 100644 --- a/Sources/WalletPasses/PassBuilder.swift +++ b/Sources/WalletPasses/PassBuilder.swift @@ -1,9 +1,11 @@ import Crypto import Foundation @_spi(CMS) import X509 -import Zip +import ZipArchive /// A tool that generates pass content bundles. +/// +/// > Warning: You can only sign passes with the same pass type identifier of the certificates used to initialize the ``PassBuilder``. public struct PassBuilder: Sendable { private let pemWWDRCertificate: String private let pemCertificate: String @@ -37,7 +39,7 @@ public struct PassBuilder: Sendable { self.pemCertificate = pemCertificate self.pemPrivateKey = pemPrivateKey self.pemPrivateKeyPassword = pemPrivateKeyPassword - self.openSSLURL = URL(fileURLWithPath: openSSLPath) + self.openSSLURL = URL(filePath: openSSLPath) } /// Generates a signature for a given personalization token. @@ -50,19 +52,19 @@ public struct PassBuilder: Sendable { public func signature(for data: Data) throws -> Data { // Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that. if let pemPrivateKeyPassword { - guard FileManager.default.fileExists(atPath: self.openSSLURL.path) else { + guard FileManager.default.fileExists(atPath: self.openSSLURL.path()) else { throw WalletPassesError.noOpenSSLExecutable } - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } - let manifestURL = tempDir.appendingPathComponent(Self.manifestFileName) - let wwdrURL = tempDir.appendingPathComponent("wwdr.pem") - let certificateURL = tempDir.appendingPathComponent("certificate.pem") - let privateKeyURL = tempDir.appendingPathComponent("private.pem") - let signatureURL = tempDir.appendingPathComponent(Self.signatureFileName) + let manifestURL = tempDir.appending(path: Self.manifestFileName) + let wwdrURL = tempDir.appending(path: "wwdr.pem") + let certificateURL = tempDir.appending(path: "certificate.pem") + let privateKeyURL = tempDir.appending(path: "private.pem") + let signatureURL = tempDir.appending(path: Self.signatureFileName) try data.write(to: manifestURL) try self.pemWWDRCertificate.write(to: wwdrURL, atomically: true, encoding: .utf8) @@ -74,11 +76,11 @@ public struct PassBuilder: Sendable { process.executableURL = self.openSSLURL process.arguments = [ "smime", "-binary", "-sign", - "-certfile", wwdrURL.path, - "-signer", certificateURL.path, - "-inkey", privateKeyURL.path, - "-in", manifestURL.path, - "-out", signatureURL.path, + "-certfile", wwdrURL.path(), + "-signer", certificateURL.path(), + "-inkey", privateKeyURL.path(), + "-in", manifestURL.path(), + "-out", signatureURL.path(), "-outform", "DER", "-passin", "pass:\(pemPrivateKeyPassword)", ] @@ -114,31 +116,25 @@ public struct PassBuilder: Sendable { sourceFilesDirectoryPath: String, personalization: PersonalizationJSON? = nil ) throws -> Data { - let filesDirectory = URL(fileURLWithPath: sourceFilesDirectoryPath, isDirectory: true) - guard - (try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - else { + let filesDirectory = URL(filePath: sourceFilesDirectoryPath, directoryHint: .isDirectory) + guard (try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { throw WalletPassesError.noSourceFiles } - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.copyItem(at: filesDirectory, to: tempDir) - defer { try? FileManager.default.removeItem(at: tempDir) } - - var archiveFiles: [ArchiveFile] = [] + var archiveFiles: [String: Data] = [:] + var manifestJSON: [String: String] = [:] let passJSON = try self.encoder.encode(pass) - try passJSON.write(to: tempDir.appendingPathComponent("pass.json")) - archiveFiles.append(ArchiveFile(filename: "pass.json", data: passJSON)) + archiveFiles["pass.json"] = passJSON + manifestJSON["pass.json"] = passJSON.manifestHash - // Pass Personalization if let personalization { let personalizationJSONData = try self.encoder.encode(personalization) - try personalizationJSONData.write(to: tempDir.appendingPathComponent("personalization.json")) - archiveFiles.append(ArchiveFile(filename: "personalization.json", data: personalizationJSONData)) + archiveFiles["personalization.json"] = personalizationJSONData + manifestJSON["personalization.json"] = personalizationJSONData.manifestHash } - let sourceFilesPaths = try FileManager.default.subpathsOfDirectory(atPath: tempDir.path) + let sourceFilesPaths = try FileManager.default.subpathsOfDirectory(atPath: filesDirectory.path()) if personalization != nil { guard @@ -160,32 +156,30 @@ public struct PassBuilder: Sendable { throw WalletPassesError.noIcon } - var manifestJSON: [String: String] = [:] - for relativePath in sourceFilesPaths { - let fileURL = URL(fileURLWithPath: relativePath, relativeTo: tempDir) - - guard !fileURL.hasDirectoryPath else { - continue - } - - guard !(fileURL.lastPathComponent == ".gitkeep" || fileURL.lastPathComponent == ".DS_Store") else { - continue - } + let fileURL = URL(filePath: relativePath, directoryHint: .checkFileSystem, relativeTo: filesDirectory) + guard !fileURL.hasDirectoryPath else { continue } + if fileURL.lastPathComponent == ".gitkeep" || fileURL.lastPathComponent == ".DS_Store" { continue } let fileData = try Data(contentsOf: fileURL) - - archiveFiles.append(ArchiveFile(filename: relativePath, data: fileData)) - - manifestJSON[relativePath] = Insecure.SHA1.hash(data: fileData).map { "0\(String($0, radix: 16))".suffix(2) }.joined() + archiveFiles[relativePath] = fileData + manifestJSON[relativePath] = fileData.manifestHash } let manifestData = try self.encoder.encode(manifestJSON) - archiveFiles.append(ArchiveFile(filename: Self.manifestFileName, data: manifestData)) - try archiveFiles.append(ArchiveFile(filename: Self.signatureFileName, data: self.signature(for: manifestData))) + archiveFiles[Self.manifestFileName] = manifestData + try archiveFiles[Self.signatureFileName] = self.signature(for: manifestData) + + let writer = ZipArchiveWriter() + for (filename, contents) in archiveFiles { + try writer.writeFile(filename: filename, contents: Array(contents)) + } + return try Data(writer.finalizeBuffer()) + } +} - let zipFile = tempDir.appendingPathComponent("\(UUID().uuidString).pkpass") - try Zip.zipData(archiveFiles: archiveFiles, zipFilePath: zipFile) - return try Data(contentsOf: zipFile) +extension Data { + fileprivate var manifestHash: String { + Insecure.SHA1.hash(data: self).map { "0\(String($0, radix: 16))".suffix(2) }.joined() } } diff --git a/Tests/WalletOrdersTests/WalletOrdersTests.swift b/Tests/WalletOrdersTests/WalletOrdersTests.swift index c62f432..8e6a523 100644 --- a/Tests/WalletOrdersTests/WalletOrdersTests.swift +++ b/Tests/WalletOrdersTests/WalletOrdersTests.swift @@ -2,17 +2,13 @@ import Crypto import Foundation import Testing import WalletOrders -import Zip +import ZipArchive @Suite("WalletOrders Tests") struct WalletOrdersTests { let decoder = JSONDecoder() let order = TestOrder() - init() { - Zip.addCustomFileExtension("order") - } - @Test("Build Order") func build() throws { let builder = OrderBuilder( @@ -81,26 +77,23 @@ struct WalletOrdersTests { } private func testRoundTripped(_ bundle: Data) throws { - let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).order") - try bundle.write(to: orderURL) - let orderFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try Zip.unzipFile(orderURL, destination: orderFolder) + let reader = try ZipArchiveReader(buffer: bundle) + let directory = try reader.readDirectory() - #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) + #expect(directory.contains { $0.filename == "signature" }) - #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/pet_store_logo.png"))) - #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/it-IT.lproj/pet_store_logo.png"))) + #expect(directory.contains { $0.filename == "pet_store_logo.png" }) + #expect(directory.contains { $0.filename == "it-IT.lproj/pet_store_logo.png" }) - #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/order.json"))) - let orderData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) - let roundTrippedOrder = try decoder.decode(TestOrder.self, from: orderData!) + let orderBytes = try reader.readFile(#require(directory.first { $0.filename == "order.json" })) + let roundTrippedOrder = try decoder.decode(TestOrder.self, from: Data(orderBytes)) #expect(roundTrippedOrder.authenticationToken == order.authenticationToken) #expect(roundTrippedOrder.orderIdentifier == order.orderIdentifier) - let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8) - let manifestJSON = try decoder.decode([String: String].self, from: manifestJSONData!) - let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png")) - #expect(manifestJSON["icon.png"] == SHA256.hash(data: iconData).map { "0\(String($0, radix: 16))".suffix(2) }.joined()) + let manifestJSONBytes = try reader.readFile(#require(directory.first { $0.filename == "manifest.json" })) + let manifestJSON = try decoder.decode([String: String].self, from: Data(manifestJSONBytes)) + let iconBytes = try reader.readFile(#require(directory.first { $0.filename == "icon.png" })) + #expect(manifestJSON["icon.png"] == SHA256.hash(data: iconBytes).map { "0\(String($0, radix: 16))".suffix(2) }.joined()) #expect(manifestJSON["pet_store_logo.png"] != nil) #expect(manifestJSON["it-IT.lproj/pet_store_logo.png"] != nil) } diff --git a/Tests/WalletPassesTests/WalletPassesTests.swift b/Tests/WalletPassesTests/WalletPassesTests.swift index 1cb317d..08c3b51 100644 --- a/Tests/WalletPassesTests/WalletPassesTests.swift +++ b/Tests/WalletPassesTests/WalletPassesTests.swift @@ -2,17 +2,13 @@ import Crypto import Foundation import Testing import WalletPasses -import Zip +import ZipArchive @Suite("WalletPasses Tests") struct WalletPassesTests { let decoder = JSONDecoder() let pass = TestPass() - init() { - Zip.addCustomFileExtension("pkpass") - } - @Test("Build Pass") func build() throws { let builder = PassBuilder( @@ -147,35 +143,32 @@ struct WalletPassesTests { } private func testRoundTripped(_ bundle: Data, with personalization: PersonalizationJSON? = nil) throws { - let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).pkpass") - try bundle.write(to: passURL) - let passFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try Zip.unzipFile(passURL, destination: passFolder) + let reader = try ZipArchiveReader(buffer: bundle) + let directory = try reader.readDirectory() - #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) + #expect(directory.contains { $0.filename == "signature" }) - #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/logo.png"))) - #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/personalizationLogo.png"))) - #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/it-IT.lproj/logo.png"))) - #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/it-IT.lproj/personalizationLogo.png"))) + #expect(directory.contains { $0.filename == "logo.png" }) + #expect(directory.contains { $0.filename == "personalizationLogo.png" }) + #expect(directory.contains { $0.filename == "it-IT.lproj/logo.png" }) + #expect(directory.contains { $0.filename == "it-IT.lproj/personalizationLogo.png" }) - #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/pass.json"))) - let passData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8)! - let roundTrippedPass = try decoder.decode(TestPass.self, from: passData) + let passBytes = try reader.readFile(#require(directory.first { $0.filename == "pass.json" })) + let roundTrippedPass = try decoder.decode(TestPass.self, from: Data(passBytes)) #expect(roundTrippedPass.authenticationToken == pass.authenticationToken) #expect(roundTrippedPass.serialNumber == pass.serialNumber) #expect(roundTrippedPass.description == pass.description) if let personalization { - let personalizationJSONData = try String(contentsOfFile: passFolder.path.appending("/personalization.json")).data(using: .utf8) - let personalizationJSON = try decoder.decode(PersonalizationJSON.self, from: personalizationJSONData!) + let personalizationJSONBytes = try reader.readFile(#require(directory.first { $0.filename == "personalization.json" })) + let personalizationJSON = try decoder.decode(PersonalizationJSON.self, from: Data(personalizationJSONBytes)) #expect(personalizationJSON.description == personalization.description) } - let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8)! - let manifestJSON = try decoder.decode([String: String].self, from: manifestJSONData) - let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) - #expect(manifestJSON["icon.png"] == Insecure.SHA1.hash(data: iconData).map { "0\(String($0, radix: 16))".suffix(2) }.joined()) + let manifestJSONBytes = try reader.readFile(#require(directory.first { $0.filename == "manifest.json" })) + let manifestJSON = try decoder.decode([String: String].self, from: Data(manifestJSONBytes)) + let iconBytes = try reader.readFile(#require(directory.first { $0.filename == "icon.png" })) + #expect(manifestJSON["icon.png"] == Insecure.SHA1.hash(data: iconBytes).map { "0\(String($0, radix: 16))".suffix(2) }.joined()) #expect(manifestJSON["logo.png"] != nil) #expect(manifestJSON["personalizationLogo.png"] != nil) #expect(manifestJSON["it-IT.lproj/logo.png"] != nil)