Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use swift-zip-archive #4

Merged
merged 9 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@ import PackageDescription
let package = Package(
name: "swift-wallet",
platforms: [
.macOS(.v12)
.macOS(.v13)
],
products: [
.library(name: "WalletPasses", targets: ["WalletPasses"]),
.library(name: "WalletOrders", targets: ["WalletOrders"]),
],
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
),
Expand All @@ -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
),
Expand Down
90 changes: 42 additions & 48 deletions Sources/WalletOrders/OrderBuilder.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)",
]
Expand Down Expand Up @@ -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()
}
}
92 changes: 43 additions & 49 deletions Sources/WalletPasses/PassBuilder.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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)",
]
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
}
31 changes: 12 additions & 19 deletions Tests/WalletOrdersTests/WalletOrdersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand Down
Loading