Skip to content

Commit 15e4411

Browse files
authored
Extended App-Receipt Validation (#4)
* Introduced (optional) verification of the app instance based on its “identity” (`AppIdentity`)—bundle ID and version * Extended `AppReceiptValidatorTests` * Updated visibility of `SoftwareVersion.string` * Configured module exports * This change is backward-compatible
1 parent 8fb42e5 commit 15e4411

File tree

8 files changed

+138
-11
lines changed

8 files changed

+138
-11
lines changed

AppReceiptValidation/src/YMAppReceiptValidation.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
// Copyright © 2025 Yakov Manshin. See the LICENSE file for license info.
77
//
88

9+
@_exported import YMUtilities
10+
911
/// Initializes and returns an opaque-type app-receipt validator.
10-
public func makeAppReceiptValidator() -> some AppReceiptValidatorProtocol {
11-
AppReceiptValidator(proxy: AppReceiptValidatorProxy())
12+
///
13+
/// - Parameter appIdentity: *Optional.* The object which describes the checked app’s identity.
14+
/// If no value is provided, app-identity verification will be skipped.
15+
public func makeAppReceiptValidator(appIdentity: AppIdentity? = nil) -> some AppReceiptValidatorProtocol {
16+
AppReceiptValidator(
17+
proxy: AppReceiptValidatorProxy(),
18+
appIdentity: appIdentity,
19+
)
1220
}

AppReceiptValidation/src/impl/AppReceiptValidator.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import CryptoKit
1313
final actor AppReceiptValidator<Proxy: AppReceiptValidatorProxyProtocol> {
1414

1515
private let proxy: Proxy
16+
private let appIdentity: AppIdentity?
1617

17-
init(proxy: Proxy) {
18+
init(proxy: Proxy, appIdentity: AppIdentity?) {
1819
self.proxy = proxy
20+
self.appIdentity = appIdentity
1921
}
2022

2123
}
@@ -45,6 +47,12 @@ extension AppReceiptValidator: AppReceiptValidatorProtocol {
4547
return .init(result: .failure(error), transaction: verifiedTransaction)
4648
}
4749

50+
do {
51+
try verifyAppIdentity(for: verifiedTransaction)
52+
} catch {
53+
return .init(result: .failure(error), transaction: verifiedTransaction)
54+
}
55+
4856
return .init(result: .success(()), transaction: verifiedTransaction)
4957
}
5058

@@ -106,4 +114,20 @@ extension AppReceiptValidator: AppReceiptValidatorProtocol {
106114
byteSequence.map({ String(format: "%02x", $0) }).joined()
107115
}
108116

117+
func verifyAppIdentity(for transaction: AppTransactionProxy) throws(AppReceiptValidatorError) {
118+
guard let appIdentity else { return }
119+
120+
if appIdentity.bundleIdentifier != transaction.bundleID {
121+
// TODO: This will constitute a breaking change:
122+
// throw .appIdentityMismatch
123+
throw .other
124+
}
125+
126+
if appIdentity.version.string != transaction.appVersion {
127+
// TODO: This will constitute a breaking change:
128+
// throw .appIdentityMismatch
129+
throw .other
130+
}
131+
}
132+
109133
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// AppIdentity.swift
3+
// YMAppReceiptValidation
4+
//
5+
// Created by Yakov Manshin on 6/13/25.
6+
// Copyright © 2025 Yakov Manshin. See the LICENSE file for license info.
7+
//
8+
9+
/// The object that fixes the identity of the app in place.
10+
public struct AppIdentity: Sendable {
11+
12+
let bundleIdentifier: String
13+
let version: SoftwareVersion
14+
15+
/// Initializes a new `AppIdentity`.
16+
///
17+
/// + This object is supposed to be initialized using string literals. Don’t use `Bundle` as it retrieves values from `Info.plist` at runtime.
18+
///
19+
/// - Parameters:
20+
/// - bundleIdentifier: *Required.* The main bundle’s identifier.
21+
/// - version: *Required.* The main bundle’s current version.
22+
public init(bundleIdentifier: String, version: SoftwareVersion) {
23+
self.bundleIdentifier = bundleIdentifier
24+
self.version = version
25+
}
26+
27+
}

AppReceiptValidation/src/public/AppReceiptValidatorError.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,14 @@ public enum AppReceiptValidatorError: Error {
2929
/// Device-verification tags don’t match.
3030
case deviceVerificationMismatch(expected: String, actual: String)
3131

32-
/// Other (unexpected) error.
32+
// TODO: This will constitute a breaking change:
33+
// /// Failed to verify app identity.
34+
// case appIdentityMismatch
35+
36+
/// Other error.
3337
///
34-
/// + This error is never expected to be thrown.
38+
/// + Generally, this error is not expected to be thrown.
39+
/// + The exception is newly-introduced errors, thrown as `other` to preserve backward compatibility.
3540
case other
3641

3742
}
@@ -52,6 +57,9 @@ extension AppReceiptValidatorError: CustomStringConvertible, LocalizedError {
5257
"Invalid device-verification string: \(string)"
5358
case .deviceVerificationMismatch(let expected, let actual):
5459
"Device-verification data mismatched: expected (\(expected.prefix(16))…) / computed (\(actual.prefix(16))…)"
60+
// TODO: This will constitute a breaking change:
61+
// case .appIdentityMismatch:
62+
// "The app identity does not match the receipt"
5563
case .other:
5664
"An unknown error occurred"
5765
}

AppReceiptValidation/test/suites/AppReceiptValidatorTests.swift

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import Foundation
1212
import Testing
13+
import YMUtilities
1314

1415
// MARK: - Tests
1516

@@ -19,7 +20,7 @@ struct AppReceiptValidatorTests {
1920
let proxy = AppReceiptValidatorProxyStub()
2021

2122
init() {
22-
validator = AppReceiptValidator(proxy: proxy)
23+
validator = AppReceiptValidator(proxy: proxy, appIdentity: .sample)
2324
}
2425

2526
@Test func validateAppReceipt_allowUI_success_shared() async {
@@ -323,6 +324,56 @@ struct AppReceiptValidatorTests {
323324
#expect(actual == "7d30e01f8fd318434b2f5ac0a49936d14eac344505ef789fa89a8a57b5e181a0f23827d610107e0f4defe5214f9f6130")
324325
}
325326

327+
@Test func verifyAppIdentity_success() async throws {
328+
try await validator.verifyAppIdentity(for: .sample)
329+
}
330+
331+
@Test func verifyAppIdentity_success_skipped() async throws {
332+
let validator = AppReceiptValidator(proxy: proxy, appIdentity: nil)
333+
334+
try await validator.verifyAppIdentity(for: .sample)
335+
}
336+
337+
@Test func verifyAppIdentity_failure_bundleID() async throws {
338+
let transaction = AppTransactionProxy(
339+
bundleID: "TEST_AnotherBundleID",
340+
environment: .production,
341+
appVersion: "1.23.45",
342+
originalAppVersion: "",
343+
purchaseDate: Date(),
344+
deviceVerificationNonce: .allZero,
345+
deviceVerification: Data()
346+
)
347+
348+
let error = try await #require(throws: AppReceiptValidatorError.self) {
349+
try await validator.verifyAppIdentity(for: transaction)
350+
}
351+
guard case .other = error else {
352+
Issue.record()
353+
return
354+
}
355+
}
356+
357+
@Test func verifyAppIdentity_failure_appVersion() async throws {
358+
let transaction = AppTransactionProxy(
359+
bundleID: "TEST_BundleID",
360+
environment: .sandbox,
361+
appVersion: "10.0.0",
362+
originalAppVersion: "",
363+
purchaseDate: Date(),
364+
deviceVerificationNonce: .allZero,
365+
deviceVerification: Data()
366+
)
367+
368+
let error = try await #require(throws: AppReceiptValidatorError.self) {
369+
try await validator.verifyAppIdentity(for: transaction)
370+
}
371+
guard case .other = error else {
372+
Issue.record()
373+
return
374+
}
375+
}
376+
326377
}
327378

328379
// MARK: - Utilities
@@ -332,7 +383,7 @@ fileprivate extension AppTransactionProxy {
332383
static let sample = AppTransactionProxy(
333384
bundleID: "TEST_BundleID",
334385
environment: .other("TEST_Environment"),
335-
appVersion: "TEST_AppVersion",
386+
appVersion: "1.23.45",
336387
originalAppVersion: "TEST_OriginalAppVersion",
337388
purchaseDate: Date(timeIntervalSince1970: 1521288000),
338389
deviceVerificationNonce: .allZero,
@@ -378,3 +429,7 @@ fileprivate extension UUID {
378429
static let allZero = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
379430
static let nonZero = UUID(uuidString: "01234567-89AB-CDEF-0123-456789ABCDEF")!
380431
}
432+
433+
fileprivate extension AppIdentity {
434+
static let sample = AppIdentity(bundleIdentifier: "TEST_BundleID", version: SoftwareVersion("1.23.45")!)
435+
}

AppValidation/src/YMAppValidation.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
// Copyright © 2025 Yakov Manshin. See the LICENSE file for license info.
77
//
88

9-
import YMAppReceiptValidation
9+
@_exported import YMAppReceiptValidation
10+
@_exported import YMUtilities
1011

1112
/// Initializes and returns an opaque-type app validator.
12-
public func makeAppValidator() -> some AppValidatorProtocol {
13-
AppValidator(appReceiptValidator: makeAppReceiptValidator())
13+
///
14+
/// - Parameter appIdentity: *Optional.* The object which describes the checked app’s identity.
15+
/// If no value is provided, app-identity verification will be skipped.
16+
public func makeAppValidator(appIdentity: AppIdentity? = nil) -> some AppValidatorProtocol {
17+
AppValidator(appReceiptValidator: makeAppReceiptValidator(appIdentity: appIdentity))
1418
}

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ private func darwinTargets() -> [Target] {
4747
[
4848
.target(
4949
name: "YMAppReceiptValidation",
50+
dependencies: ["YMUtilities"],
5051
path: "AppReceiptValidation/src",
5152
),
5253
.testTarget(

Utilities/src/SoftwareVersion.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public struct SoftwareVersion: Sendable {
2828

2929
extension SoftwareVersion {
3030

31-
var string: String {
31+
public var string: String {
3232
"\(major).\(minor).\(patch)"
3333
}
3434

0 commit comments

Comments
 (0)