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

Add support for Xcode and LocalTesting environments #19

Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.swiftpm

.DS_Store
docs/
.build/

Packages/
Expand Down
6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-asn1.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-asn1.git", from: "1.1.0"),
.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "4.0.0"),
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.0.0"),
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
Expand All @@ -40,6 +40,8 @@ let package = Package(
]),
.testTarget(
name: "AppStoreServerLibraryTests",
dependencies: ["AppStoreServerLibrary"]),
dependencies: ["AppStoreServerLibrary"],
resources: [.copy("resources")]
),
]
)
10 changes: 8 additions & 2 deletions Sources/AppStoreServerLibrary/ChainVerifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ struct ChainVerifier {
self.store = CertificateStore(parsedCertificates)
}

func verify<T: DecodedSignedData>(signedData: String, type: T.Type, onlineVerification: Bool) async -> VerificationResult<T> where T: Decodable {
func verify<T: DecodedSignedData>(signedData: String, type: T.Type, onlineVerification: Bool, environment: Environment) async -> VerificationResult<T> where T: Decodable {
let header: JWTHeader;
let decodedBody: T;
do {
Expand All @@ -40,6 +40,12 @@ struct ChainVerifier {
return VerificationResult.invalid(VerificationError.INVALID_JWT_FORMAT)
}

if (environment == Environment.xcode || environment == Environment.localTesting) {
// Data is not signed by the App Store, and verification should be skipped
// The environment MUST be checked in the public method calling this
return VerificationResult.valid(decodedBody)
}

guard let x5c_header = header.x5c else {
return VerificationResult.invalid(VerificationError.INVALID_JWT_FORMAT)
}
Expand Down Expand Up @@ -111,7 +117,7 @@ class VaporBody : JWTPayload {
}
}

struct JWTHeader: Decodable {
struct JWTHeader: Decodable, Encodable {
public var alg: String?
public var x5c: [String]?
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/AppStoreServerLibrary/Models/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
public enum Environment: String, Decodable, Encodable, Hashable {
case sandbox = "Sandbox"
case production = "Production"
case xcode = "Xcode"
case localTesting = "LocalTesting"
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,5 @@ public struct JWSRenewalInfoDecodedPayload: DecodedSignedData, Decodable, Encoda
///The UNIX time, in milliseconds, when the most recent auto-renewable subscription purchase expires.
///
///[renewalDate](https://developer.apple.com/documentation/appstoreserverapi/renewaldate)
public var renewaldate: Date?
public var renewalDate: Date?
}
40 changes: 20 additions & 20 deletions Sources/AppStoreServerLibrary/ReceiptUtility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@ public class ReceiptUtility {
///- Returns A transaction id from the array of in-app purchases, null if the receipt contains no in-app purchases
public static func extractTransactionId(appReceipt: String) -> String? {
var result: String? = nil
if let parsedData = Foundation.Data(base64Encoded: appReceipt), let parsedContainer = try? DER.parse([UInt8](parsedData)) {
try? DER.sequence(parsedContainer, identifier: ASN1Identifier.sequence) { nodes in
let _ = try ASN1ObjectIdentifier(derEncoded: &nodes)
try? DER.optionalExplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { arrayNode in
try? DER.sequence(arrayNode, identifier: ASN1Identifier.sequence) { nodes in
if let parsedData = Foundation.Data(base64Encoded: appReceipt), let parsedContainer = try? BER.parse([UInt8](parsedData)) {
try? BER.sequence(parsedContainer, identifier: ASN1Identifier.sequence) { nodes in
let _ = try ASN1ObjectIdentifier(berEncoded: &nodes)
try? BER.optionalExplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { arrayNode in
try? BER.sequence(arrayNode, identifier: ASN1Identifier.sequence) { nodes in
var _ = nodes.next()
_ = nodes.next()
if let contentInfo = nodes.next() {
try? DER.sequence(contentInfo, identifier: ASN1Identifier.sequence) { nodes in
try? BER.sequence(contentInfo, identifier: ASN1Identifier.sequence) { nodes in
_ = nodes.next()
try? DER.optionalExplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { arrayNode in
let content = try ASN1OctetString(derEncoded: arrayNode)
try? BER.optionalExplicitlyTagged(&nodes, tagNumber: 0, tagClass: .contextSpecific) { arrayNode in
let content = try ASN1OctetString(berEncoded: arrayNode)
result = extractTransactionIdFromAppReceiptInner(appReceiptContent: content)
}
}
Expand All @@ -42,13 +42,13 @@ public class ReceiptUtility {

private static func extractTransactionIdFromAppReceiptInner(appReceiptContent: ASN1OctetString) -> String? {
var result: String? = nil
if let parsedAppReceipt = try? DER.parse([UInt8](appReceiptContent.bytes)) {
try? DER.sequence(parsedAppReceipt, identifier: ASN1Identifier.set) { nodes in
if let parsedAppReceipt = try? BER.parse([UInt8](appReceiptContent.bytes)) {
try? BER.sequence(parsedAppReceipt, identifier: ASN1Identifier.set) { nodes in
while let node = nodes.next() {
try? DER.sequence(node, identifier: ASN1Identifier.sequence) { sequenceNodes in
try? BER.sequence(node, identifier: ASN1Identifier.sequence) { sequenceNodes in
if let typeEncoded = sequenceNodes.next(), sequenceNodes.next() != nil, let valueEncoded = sequenceNodes.next() {
let type = try? Int64(derEncoded: typeEncoded, withIdentifier: ASN1Identifier.integer)
let value = try? ASN1OctetString(derEncoded: valueEncoded)
let type = try? Int64(berEncoded: typeEncoded, withIdentifier: ASN1Identifier.integer)
let value = try? ASN1OctetString(berEncoded: valueEncoded)
if type == IN_APP_TYPE_ID, let unwrappedValue = value {
result = extractTransactionIdFromInAppReceipt(inAppReceiptContent: unwrappedValue)
}
Expand All @@ -62,16 +62,16 @@ public class ReceiptUtility {

private static func extractTransactionIdFromInAppReceipt(inAppReceiptContent: ASN1OctetString) -> String? {
var result: String? = nil
if let parsedInAppReceipt = try? DER.parse([UInt8](inAppReceiptContent.bytes)) {
try? DER.sequence(parsedInAppReceipt, identifier: ASN1Identifier.set) { nodes in
if let parsedInAppReceipt = try? BER.parse([UInt8](inAppReceiptContent.bytes)) {
try? BER.sequence(parsedInAppReceipt, identifier: ASN1Identifier.set) { nodes in
while let node = nodes.next() {
try? DER.sequence(node, identifier: ASN1Identifier.sequence) { sequenceNodes in
try? BER.sequence(node, identifier: ASN1Identifier.sequence) { sequenceNodes in
if let typeEncoded = sequenceNodes.next(), sequenceNodes.next() != nil, let valueEncoded = sequenceNodes.next() {
let type = try? Int64(derEncoded: typeEncoded, withIdentifier: ASN1Identifier.integer)
let value = try? ASN1OctetString(derEncoded: valueEncoded)
let type = try? Int64(berEncoded: typeEncoded, withIdentifier: ASN1Identifier.integer)
let value = try? ASN1OctetString(berEncoded: valueEncoded)
if type == TRANSACTION_IDENTIFIER_TYPE_ID || type == ORIGINAL_TRANSACTION_IDENTIFIER_TYPE_ID, let unwrappedValue = value {
if let parseResult = try? DER.parse(unwrappedValue.bytes) {
if let utf8String = try? ASN1UTF8String(derEncoded: parseResult, withIdentifier: .utf8String) {
if let parseResult = try? BER.parse(unwrappedValue.bytes) {
if let utf8String = try? ASN1UTF8String(berEncoded: parseResult, withIdentifier: .utf8String) {
result = String(utf8String)
}
}
Expand Down
15 changes: 12 additions & 3 deletions Sources/AppStoreServerLibrary/SignedDataVerifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@ public struct SignedDataVerifier {
/// - Parameter signedRenewalInfo The signedRenewalInfo field
/// - Returns: If success, the decoded renewal info after verification, else the reason for verification failure
public func verifyAndDecodeRenewalInfo(signedRenewalInfo: String) async -> VerificationResult<JWSRenewalInfoDecodedPayload> {
return await decodeSignedData(signedData: signedRenewalInfo, type: JWSRenewalInfoDecodedPayload.self)
let renewalInfoResult = await decodeSignedData(signedData: signedRenewalInfo, type: JWSRenewalInfoDecodedPayload.self)
switch renewalInfoResult {
case .valid(let renewalInfo):
if self.environment != renewalInfo.environment {
return VerificationResult.invalid(VerificationError.INVALID_ENVIRONMENT)
}
case .invalid(_):
break
}
return renewalInfoResult
}
/// Verifies and decodes a signedTransaction obtained from the App Store Server API, an App Store Server Notification, or from a device
///
Expand Down Expand Up @@ -94,7 +103,7 @@ public struct SignedDataVerifier {
return appTransactionResult
}

private func decodeSignedData<T: DecodedSignedData>(signedData: String, type: T.Type) async -> VerificationResult<T> where T : Decodable{
return await chainVerifier.verify(signedData: signedData, type: type, onlineVerification: self.enableOnlineChecks)
private func decodeSignedData<T: DecodedSignedData>(signedData: String, type: T.Type) async -> VerificationResult<T> where T : Decodable {
return await chainVerifier.verify(signedData: signedData, type: type, onlineVerification: self.enableOnlineChecks, environment: self.environment)
}
}
62 changes: 62 additions & 0 deletions Sources/AppStoreServerLibrary/Utility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2023 Apple Inc. Licensed under MIT License.

import Foundation

internal func base64URLToBase64(_ encodedString: String) -> String {
let replacedString = encodedString
.replacingOccurrences(of: "/", with: "+")
.replacingOccurrences(of: "_", with: "-")
if (replacedString.count % 4 != 0) {
return replacedString + String(repeating: "=", count: 4 - replacedString.count % 4)
}
return replacedString
}

internal func getJsonDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
decoder.keyDecodingStrategy = .custom { keys in
return RawValueCodingKey(decodingKey: keys.last!)
}
return decoder
}

internal func getJsonEncoder() -> JSONEncoder {
let decoder = JSONEncoder()
decoder.dateEncodingStrategy = .millisecondsSince1970
decoder.keyEncodingStrategy = .custom { keys in
return RawValueCodingKey(encodingKey: keys.last!)
}
return decoder
}

private struct RawValueCodingKey: CodingKey {

private static let keysToRawKeys = ["environment": "rawEnvironment", "receiptType": "rawReceiptType", "consumptionStatus": "rawConsumptionStatus", "platform": "rawPlatform", "deliveryStatus": "rawDeliveryStatus", "accountTenure": "rawAccountTenure", "playTime": "rawPlayTime", "lifetimeDollarsRefunded": "rawLifetimeDollarsRefunded", "lifetimeDollarsPurchased": "rawLifetimeDollarsPurchased", "userStatus": "rawUserStatus", "status": "rawStatus", "expirationIntent": "rawExpirationIntent", "priceIncreaseStatus": "rawPriceIncreaseStatus", "offerType": "rawOfferType", "type": "rawType", "inAppOwnershipType": "rawInAppOwnershipType", "revocationReason": "rawRevocationReason", "transactionReason": "rawTransactionReason", "offerDiscountType": "rawOfferDiscountType", "notificationType": "rawNotificationType", "subtype": "rawSubtype", "sendAttemptResult": "rawSendAttemptResult", "autoRenewStatus": "rawAutoRenewStatus"]
private static let rawKeysToKeys = ["rawEnvironment": "environment", "rawReceiptType": "receiptType", "rawConsumptionStatus": "consumptionStatus", "rawPlatform": "platform", "rawDeliveryStatus": "deliveryStatus", "rawAccountTenure": "accountTenure", "rawPlayTime": "playTime", "rawLifetimeDollarsRefunded": "lifetimeDollarsRefunded", "rawLifetimeDollarsPurchased": "lifetimeDollarsPurchased", "rawUserStatus": "userStatus", "rawStatus": "status", "rawExpirationIntent": "expirationIntent", "rawPriceIncreaseStatus": "priceIncreaseStatus", "rawOfferType": "offerType", "rawType": "type", "rawInAppOwnershipType": "inAppOwnershipType", "rawRevocationReason": "revocationReason", "rawTransactionReason": "transactionReason", "rawOfferDiscountType": "offerDiscountType", "rawNotificationType": "notificationType", "rawSubtype": "subtype", "rawSendAttemptResult": "sendAttemptResult", "rawAutoRenewStatus": "autoRenewStatus"]

var stringValue: String
var intValue: Int?

init(stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}

init(intValue: Int) {
self.stringValue = String(intValue)
self.intValue = intValue
}

init(decodingKey: CodingKey) {
let decodingKeyString = decodingKey.stringValue
self.stringValue = RawValueCodingKey.keysToRawKeys[decodingKeyString, default: decodingKeyString]
self.intValue = nil
}

init(encodingKey: CodingKey) {
let encodingKeyString = encodingKey.stringValue
self.stringValue = RawValueCodingKey.rawKeysToKeys[encodingKeyString, default: encodingKeyString]
self.intValue = nil
}
}
36 changes: 36 additions & 0 deletions Tests/AppStoreServerLibraryTests/ReceiptUtilityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2023 Apple Inc. Licensed under MIT License.

import XCTest
@testable import AppStoreServerLibrary

import X509

final class ReceiptUtilityTests: XCTestCase {

private let APP_RECEIPT_EXPECTED_TRANSACTION_ID = "0"
private let TRANSACTION_RECEIPT_EXPECTED_TRANSACTION_ID = "33993399"

public func testXcodeAppReceiptExtractionWithNoTransactions() throws {
let receipt = TestingUtility.readFile("resources/xcode/xcode-app-receipt-empty")

let extractedTransactionId = ReceiptUtility.extractTransactionId(appReceipt: receipt)

XCTAssertNil(extractedTransactionId)
}

public func testXcodeAppReceiptExtractionWithTransactions() throws {
let receipt = TestingUtility.readFile("resources/xcode/xcode-app-receipt-with-transaction")

let extractedTransactionId = ReceiptUtility.extractTransactionId(appReceipt: receipt)

XCTAssertEqual(APP_RECEIPT_EXPECTED_TRANSACTION_ID, extractedTransactionId)
}

public func testTransactionReceiptExtraction() throws {
let receipt = TestingUtility.readFile("resources/mock_signed_data/legacyTransaction")

let extractedTransactionId = ReceiptUtility.extractTransactionId(transactionReceipt: receipt)

XCTAssertEqual(TRANSACTION_RECEIPT_EXPECTED_TRANSACTION_ID, extractedTransactionId)
}
}
Loading