diff --git a/AppReceiptValidator/AppReceiptValidator Tests Shared/ReceiptDateFormatterTests.swift b/AppReceiptValidator/AppReceiptValidator Tests Shared/ReceiptDateFormatterTests.swift new file mode 100644 index 0000000..40c0a97 --- /dev/null +++ b/AppReceiptValidator/AppReceiptValidator Tests Shared/ReceiptDateFormatterTests.swift @@ -0,0 +1,28 @@ +// +// ReceiptDateFormatterTests.swift +// AppReceiptValidator +// +// Created by Hannes Oud on 09.10.20. +// Copyright © 2020 IdeasOnCanvas GmbH. All rights reserved. +// + +import AppReceiptValidator +import Foundation +import XCTest + + +final class ReceiptDateFormatterTests: XCTestCase { + + func testDateFormatting() throws { + let dateStrings = [ + "2020-01-01T12:00:00Z", + "2020-01-01T12:00:00.123Z", + "2020-01-01T12:00:00.999Z", + "2020-01-01T12:00:01Z" + ] + for dateString in dateStrings { + let parsed = try XCTUnwrap(AppReceiptValidator.ReceiptDateFormatter.date(from: dateString)) + XCTAssertEqual(AppReceiptValidator.ReceiptDateFormatter.string(from: parsed), dateString) + } + } +} diff --git a/AppReceiptValidator/AppReceiptValidator.xcodeproj/project.pbxproj b/AppReceiptValidator/AppReceiptValidator.xcodeproj/project.pbxproj index 9af5459..dec06a5 100644 --- a/AppReceiptValidator/AppReceiptValidator.xcodeproj/project.pbxproj +++ b/AppReceiptValidator/AppReceiptValidator.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ D114544621A6BDE6001BEC61 /* DeviceIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D114544521A6BDE6001BEC61 /* DeviceIdentifierTests.swift */; }; D114544721A6BDE6001BEC61 /* DeviceIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D114544521A6BDE6001BEC61 /* DeviceIdentifierTests.swift */; }; + D11B81CF2530687D00E19863 /* ReceiptDateFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11B81CE2530687D00E19863 /* ReceiptDateFormatterTests.swift */; }; + D11B81D02530687D00E19863 /* ReceiptDateFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11B81CE2530687D00E19863 /* ReceiptDateFormatterTests.swift */; }; D1239FFF1F6A7B5000D0421E /* AppleIncRootCertificate.cer in Resources */ = {isa = PBXBuildFile; fileRef = D19095C41F601DEA0095729B /* AppleIncRootCertificate.cer */; }; D123A0001F6A7CCF00D0421E /* AppleIncRootCertificate.cer in Resources */ = {isa = PBXBuildFile; fileRef = D19095C41F601DEA0095729B /* AppleIncRootCertificate.cer */; }; D13E5B7D20331B9B001880F0 /* DropAcceptingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D13E5B7C20331B9B001880F0 /* DropAcceptingTextView.swift */; }; @@ -278,6 +280,7 @@ /* Begin PBXFileReference section */ D114544521A6BDE6001BEC61 /* DeviceIdentifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentifierTests.swift; sourceTree = ""; }; + D11B81CE2530687D00E19863 /* ReceiptDateFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptDateFormatterTests.swift; sourceTree = ""; }; D13E5B7C20331B9B001880F0 /* DropAcceptingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropAcceptingTextView.swift; sourceTree = ""; }; D14FA72E1F6143C400545540 /* Date+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Convenience.swift"; sourceTree = ""; }; D14FA7311F61472400545540 /* mac_mindnode_rebought_receipt */ = {isa = PBXFileReference; lastKnownFileType = file; path = mac_mindnode_rebought_receipt; sourceTree = ""; }; @@ -606,6 +609,7 @@ D1D6F5411F5D8A3800E86FE1 /* AppReceiptValidationTests.swift */, D1AA845A1F6ABB31007F2558 /* AppReceiptPropertyValidationTests.swift */, D150A0ED1F669A880026ED04 /* AppReceiptValidationInAppPurchaseTests.swift */, + D11B81CE2530687D00E19863 /* ReceiptDateFormatterTests.swift */, D114544521A6BDE6001BEC61 /* DeviceIdentifierTests.swift */, D1D6F5481F5D9B1100E86FE1 /* Tools */, D1D6F5431F5D8DBC00E86FE1 /* Test Assets */, @@ -1403,6 +1407,7 @@ D19095CD1F601E960095729B /* AppReceiptValidationTests.swift in Sources */, D1AA845D1F6ABB59007F2558 /* AppReceiptPropertyValidationTests.swift in Sources */, D150A0EF1F669A880026ED04 /* AppReceiptValidationInAppPurchaseTests.swift in Sources */, + D11B81D02530687D00E19863 /* ReceiptDateFormatterTests.swift in Sources */, D114544721A6BDE6001BEC61 /* DeviceIdentifierTests.swift in Sources */, D150A0F01F67E0990026ED04 /* Date+Convenience.swift in Sources */, ); @@ -1416,6 +1421,7 @@ D19095CE1F601E980095729B /* AppReceiptValidationTests.swift in Sources */, D1AA845C1F6ABB59007F2558 /* AppReceiptPropertyValidationTests.swift in Sources */, D150A0EE1F669A880026ED04 /* AppReceiptValidationInAppPurchaseTests.swift in Sources */, + D11B81CF2530687D00E19863 /* ReceiptDateFormatterTests.swift in Sources */, D114544621A6BDE6001BEC61 /* DeviceIdentifierTests.swift in Sources */, D150A0F11F67E0990026ED04 /* Date+Convenience.swift in Sources */, ); diff --git a/AppReceiptValidator/AppReceiptValidator/AppReceiptValidator.swift b/AppReceiptValidator/AppReceiptValidator/AppReceiptValidator.swift index 20474e4..bf3cd88 100644 --- a/AppReceiptValidator/AppReceiptValidator/AppReceiptValidator.swift +++ b/AppReceiptValidator/AppReceiptValidator/AppReceiptValidator.swift @@ -88,16 +88,6 @@ public struct AppReceiptValidator { let receiptContainer = try self.extractPKCS7Container(data: receiptData) return try parseReceipt(pkcs7: receiptContainer, parseUnofficialParts: true) } - - /// Uses receipt-conform representation of dates like "2017-01-01T12:00:00Z" - public static let asn1DateFormatter: DateFormatter = { - // Date formatter code from https://www.objc.io/issues/17-security/receipt-validation/#parsing-the-receipt - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'" - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - return dateFormatter - }() } // MARK: - Full Validation @@ -351,6 +341,59 @@ private extension AppReceiptValidator { } } +// MARK: - ReceiptDateFormatter + +extension AppReceiptValidator { + + /// Static formatting methods to use for string encoded date values in receipts + public enum ReceiptDateFormatter { + + /// Uses receipt-conform representation of dates like "2017-01-01T12:00:00Z", + /// as a fallback, dates like "2017-01-01T12:00:00.123Z" are also parsed. + public static func date(from string: String) -> Date? { + return self.asn1DateFormatter.date(from: string) // expected + ?? self.fallbackDateFormatterWithMS.date(from: string) // try again with milliseconds + } + + /// Returns receipt-conform string representation of dates like "2017-01-01T12:00:00Z", + /// but if the date has sub-second fractions a millisecond representation like "2017-01-01T12:00:00.123Z" is returned. + public static func string(from date: Date) -> String { + if floor(date.timeIntervalSince1970) == date.timeIntervalSince1970 { + // Integer seconds granularity is what we expect + return self.asn1DateFormatter.string(from: date) + } else { + // millis seconds granularity is what we expect + return self.fallbackDateFormatterWithMS.string(from: date) + } + } + + /// Uses receipt-conform representation of dates like "2017-01-01T12:00:00Z" + static let asn1DateFormatter: DateFormatter = { + // Date formatter code from https://www.objc.io/issues/17-security/receipt-validation/#parsing-the-receipt + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + return dateFormatter + }() + + /// Uses receipt-conform representation of dates like "2017-01-01T12:00:00.123Z" + /// + /// This is not the officially intended format, but added after hearing reports about new format adding ms https://twitter.com/depth42/status/1314179654811607041 + private static let fallbackDateFormatterWithMS: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'SSS'Z'" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + return dateFormatter + }() + } + + /// Uses receipt-conform representation of dates like "2017-01-01T12:00:00Z" + @available(*, deprecated, message: "Use AppReceiptValidator.ReceiptDateFormatter.string(from:) or AppReceiptValidator.ReceiptDateFormatter.date(from:) instead, to cover unexpected date formats") + public static let asn1DateFormatter: DateFormatter = ReceiptDateFormatter.asn1DateFormatter +} + // MARK: - Result extension AppReceiptValidator { diff --git a/AppReceiptValidator/AppReceiptValidator/OpenSSL/ASN1Helpers.swift b/AppReceiptValidator/AppReceiptValidator/OpenSSL/ASN1Helpers.swift index 75ec66a..131c492 100644 --- a/AppReceiptValidator/AppReceiptValidator/OpenSSL/ASN1Helpers.swift +++ b/AppReceiptValidator/AppReceiptValidator/OpenSSL/ASN1Helpers.swift @@ -153,7 +153,7 @@ extension ASN1Object { var dateValue: Date? { guard let string = self.stringValue else { return nil } - return AppReceiptValidator.asn1DateFormatter.date(from: string) + return AppReceiptValidator.ReceiptDateFormatter.date(from: string) } } diff --git a/AppReceiptValidator/AppReceiptValidator/Receipt.swift b/AppReceiptValidator/AppReceiptValidator/Receipt.swift index b2938a5..e1fa18e 100644 --- a/AppReceiptValidator/AppReceiptValidator/Receipt.swift +++ b/AppReceiptValidator/AppReceiptValidator/Receipt.swift @@ -294,7 +294,7 @@ private struct StringFormatter { func format(_ date: Date?) -> String { guard let date = date else { return fallback } - return quoted(AppReceiptValidator.asn1DateFormatter.string(from: date)) + return quoted(AppReceiptValidator.ReceiptDateFormatter.string(from: date)) } func format(_ string: String?) -> String { @@ -318,7 +318,7 @@ private func parseBase64(string: String) -> Data? { // Parses a string of type "2017-01-01T12:00:00Z" private func parseDate(string: String) -> Date? { - guard let date = AppReceiptValidator.asn1DateFormatter.date(from: string) else { + guard let date = AppReceiptValidator.ReceiptDateFormatter.date(from: string) else { assertionFailure("Date could not be parsed from string '\(string)', make sure it has a correct format, example `2017-01-01T12:00:00Z`") return nil } diff --git a/AppReceiptValidator/AppReceiptValidator/UnofficialReceipt.swift b/AppReceiptValidator/AppReceiptValidator/UnofficialReceipt.swift index 0bbbe54..e30f10b 100644 --- a/AppReceiptValidator/AppReceiptValidator/UnofficialReceipt.swift +++ b/AppReceiptValidator/AppReceiptValidator/UnofficialReceipt.swift @@ -61,7 +61,7 @@ public enum KnownUnofficialReceiptAttribute: Int32 { var parsingType: ParsingType { switch self { case .date1, .date2, .date3: - return .string + return .date case .provisioningType, .ageRating, .clientName: return .string } @@ -115,7 +115,7 @@ extension UnofficialReceipt.Entry.Value: CustomStringConvertible { case .string(let value): return "\"\(value)\"" case .date(let date): - return AppReceiptValidator.asn1DateFormatter.string(from: date) + return AppReceiptValidator.ReceiptDateFormatter.string(from: date) case .bytes(let bytes): if bytes.count == 2 && bytes.first == 12 && bytes.dropFirst().first == 0 { return "2 bytes (12, 0)"