Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
da5f952
KM-16629: use storekit 2
kp-mario-nachbaur Jun 16, 2026
00d29cb
KM-16629: resolve hasIntroOffer
kp-mario-nachbaur Jun 17, 2026
2a8d6db
KM-16629: rename to PIAProduct
kp-mario-nachbaur Jun 17, 2026
899a0ec
KM-16629: use JWS instead of receipt
kp-mario-nachbaur Jul 1, 2026
419b915
KM-16629: send receipt_version 2
kp-mario-nachbaur Jul 7, 2026
867f19e
KM-16629: fix tvOS test target
kp-mario-nachbaur Jul 7, 2026
0ef2b33
KM-16629: improve restore flow
kp-mario-nachbaur Jul 10, 2026
5b9ebfc
KM-16629: call `finish` on transactions
kp-mario-nachbaur Jul 13, 2026
d22ca2b
Merge branch 'master' into KM-16629-storekit2
kp-mario-nachbaur Jul 13, 2026
fff748e
KM-16629: apply PR suggestions
kp-mario-nachbaur Jul 13, 2026
3a073d2
KM-16629: should recover pending
kp-mario-nachbaur Jul 14, 2026
aa71f12
Merge branch 'master' into KM-16629-storekit2
kp-mario-nachbaur Jul 15, 2026
f8b6bff
KM-16629: make sure to finish transactions
kp-mario-nachbaur Jul 15, 2026
e38dc3d
KM-16629: only use verified transactions
kp-mario-nachbaur Jul 15, 2026
3492991
KM-16629: force sync if failed
kp-mario-nachbaur Jul 15, 2026
0d0eaf6
remove unused gitignores and add readme to piabase
kp-mario-nachbaur Jul 15, 2026
ac6e0fd
Merge branch 'master' into KM-16629-storekit2
kp-mario-nachbaur Jul 21, 2026
c0fda09
KM-16629: add manage subscription to debug menu
kp-mario-nachbaur Jul 24, 2026
a9ec5bb
KM-16629: PR review comments
kp-mario-nachbaur Jul 24, 2026
d7fbf4f
Merge branch 'master' into KM-16629-storekit2
kp-mario-nachbaur Jul 24, 2026
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
7 changes: 6 additions & 1 deletion LocalPackages/PIAAccount/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ let package = Package(
targets: ["PIAAccount"]
)
],
dependencies: [
.package(path: "../PIABase")
],
targets: [
// Main library target (merged for simplicity)
.target(
name: "PIAAccount",
dependencies: [],
dependencies: [
.product(name: "PIABase", package: "PIABase")
],
path: "Sources"
),
// Test target using Swift Testing
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import Foundation
import PIABase

// MARK: - iOS Sign Up Information

/// Information required for iOS account sign up with App Store receipt
public struct IOSSignupInformation: Codable, Sendable {
public struct IOSSignupInformation: Encodable, Sendable {
private let store: String = "apple_app_store"
public let receipt: String
public let receipt: JWS
private let receipt_version: Int = 2
public let email: String
public let marketing: String?
public let debug: String?

public init(
receipt: String,
receipt: JWS,
email: String,
marketing: String? = nil,
debug: String? = nil
Expand All @@ -23,17 +25,30 @@ public struct IOSSignupInformation: Codable, Sendable {
}
}

// MARK: - Restore purchase information

public struct LoginRestorePurchasePayload: Encodable {
private let store: String = "apple_app_store"
private let receipt: JWS
private let receipt_version: Int = 2

public init(receipt: JWS) {
self.receipt = receipt
}
}

// MARK: - iOS Payment Information

/// Information required for iOS payment updates
public struct IOSPaymentInformation: Codable, Sendable {
public struct IOSPaymentInformation: Encodable, Sendable {
private let store: String = "apple_app_store"
public let receipt: String
public let receipt: JWS
private let receipt_version: Int = 2
public let marketing: String
public let debug: String

public init(
receipt: String,
receipt: JWS,
marketing: String,
debug: String
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ enum APIPath: String, Sendable {
case login = "/api/client/v5/api_token"
case vpnToken = "/api/client/v5/vpn_token"
case refreshAPIToken = "/api/client/v5/refresh"
case signup = "/api/client/signup"
case signup = "/api/client/v5/signup"
case setEmail = "/api/client/account"
case loginLink = "/api/client/v2/login_link"
case logout = "/api/client/v2/expire_token"
Expand All @@ -28,15 +28,15 @@ enum APIPath: String, Sendable {
/// Returns the subdomain for this API path
var subdomain: String {
switch self {
case .login, .vpnToken, .refreshAPIToken, .validateQR, .supportedDedicatedIPCountries, .getDedicatedIP:
case .login, .vpnToken, .refreshAPIToken, .validateQR, .supportedDedicatedIPCountries, .getDedicatedIP, .signup:
return "apiv5"
case .loginLink, .logout, .accountDetails, .messages, .dedicatedIP, .renewDedicatedIP:
return "apiv2"
case .deleteAccount:
return "apiv5"
case .refreshToken:
return "apiv4"
case .signup, .setEmail, .clientStatus, .invites, .redeem, .iosPayment, .iosSubscriptions, .iosFeatureFlag:
case .setEmail, .clientStatus, .invites, .redeem, .iosPayment, .iosSubscriptions, .iosFeatureFlag:
return "api"
}
}
Expand Down
17 changes: 9 additions & 8 deletions LocalPackages/PIAAccount/Sources/PIAAccount/PIAAccountAPI.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import PIABase

/// Main API protocol for PIA Account operations.
///
Expand Down Expand Up @@ -79,16 +80,16 @@ public protocol PIAAccountAPI {
/// or network/server errors for other failures
func loginWithCredentials(username: String, password: String) async throws

/// Authenticates with an App Store receipt (iOS-specific).
/// Authenticates with a StoreKit 2 signed transaction (iOS-specific).
///
/// Use this method to authenticate users who have purchased a subscription
/// through the iOS App Store. The receipt is validated server-side.
/// through the iOS App Store. The transaction is validated server-side.
///
/// - Parameter receiptBase64: Base64-encoded App Store receipt obtained from
/// `Bundle.main.appStoreReceiptURL`
/// - Throws: `PIAAccountError` with code 400 if receipt is invalid,
/// - Parameter receipt: The JWS representation of the StoreKit 2 transaction
/// (`VerificationResult.jwsRepresentation`).
/// - Throws: `PIAAccountError` with code 400 if the transaction is invalid,
/// 401 if subscription is not active, or network/server errors
func loginWithReceipt(receiptBase64: String) async throws
func loginWithReceipt(receipt: JWS) async throws

/// Sends a login link via email.
///
Expand Down Expand Up @@ -220,10 +221,10 @@ public protocol PIAAccountAPI {
// MARK: - Subscriptions (iOS)

/// Retrieves iOS subscription information
/// - Parameter receipt: Optional App Store receipt data
/// - Parameter receipt: Optional JWS StoreKit 2 transaction
/// - Returns: Subscription information
/// - Throws: PIAAccountError if the request fails
func subscriptions(receipt: Data?) async throws -> IOSSubscriptionInformation
func subscriptions(receipt: JWS?) async throws -> IOSSubscriptionInformation

// MARK: - Payment (iOS)

Expand Down
18 changes: 8 additions & 10 deletions LocalPackages/PIAAccount/Sources/PIAAccount/PIAAccountClient.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import PIABase

/// Main implementation of PIAAccountAPI
public actor PIAAccountClient: PIAAccountAPI {
Expand Down Expand Up @@ -90,11 +91,9 @@ public actor PIAAccountClient: PIAAccountAPI {
try await refreshVPNToken()
}

public func loginWithReceipt(receiptBase64: String) async throws {
let bodyData = try JSONEncoder.piaCodable.encode([
"store": "apple_app_store",
"receipt": receiptBase64
])
public func loginWithReceipt(receipt: JWS) async throws {
let payload = LoginRestorePurchasePayload(receipt: receipt)
let bodyData = try JSONEncoder.piaCodable.encode(payload)

// Request API token
let apiTokenResponse: APITokenResponse = try await endpointManager.executeWithFailover(
Expand Down Expand Up @@ -348,7 +347,7 @@ public actor PIAAccountClient: PIAAccountAPI {

// MARK: - Subscriptions (iOS)

public func subscriptions(receipt: Data?) async throws -> IOSSubscriptionInformation {
public func subscriptions(receipt: JWS?) async throws -> IOSSubscriptionInformation {
try await refreshTokensIfNeeded()

var headers: [String: String] = [:]
Expand All @@ -359,12 +358,11 @@ public actor PIAAccountClient: PIAAccountAPI {
if let apiToken = try await tokenManager.getAPITokenString() {
headers = ["Authorization": "Token \(apiToken)"]
method = .post
var bodyDict: [String: String] = ["store": "apple_app_store"]
if let receipt = receipt {
bodyDict["receipt"] = receipt.base64EncodedString()
let payload = LoginRestorePurchasePayload(receipt: receipt)
let data = try JSONEncoder.piaCodable.encode(payload)
bodyType = .json(data)
}
let bodyData = try JSONEncoder.piaCodable.encode(bodyDict)
bodyType = .json(bodyData)
}

return try await endpointManager.executeWithFailover(
Expand Down
8 changes: 0 additions & 8 deletions LocalPackages/PIAAssets/.gitignore

This file was deleted.

24 changes: 24 additions & 0 deletions LocalPackages/PIABase/Package.swift
Comment thread
kp-mario-nachbaur marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// swift-tools-version: 6.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "PIABase",
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "PIABase",
targets: ["PIABase"]
)
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "PIABase"
)

],
swiftLanguageModes: [.v6]
)
4 changes: 4 additions & 0 deletions LocalPackages/PIABase/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# PIABase

This package holds types and utilities used by the app or other packages.
PIABase should not depend on any other packages to avoid circular dependency problems.
47 changes: 47 additions & 0 deletions LocalPackages/PIABase/Sources/PIABase/JWS.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// JWS.swift
// PIAAccount
//
// Created by Mario on 30/06/26.
// Copyright © 2020 Private Internet Access, Inc.
//
// This file is part of the Private Internet Access iOS Client.
//
// The Private Internet Access iOS Client is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any later version.
//
// The Private Internet Access iOS Client is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with the Private
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
//

import Foundation

public struct JWS: Sendable {
public let value: String

public init?(_ value: String) {
if value.isEmpty { return nil }
self.value = value
}

@inlinable
public var isEmpty: Bool { value.isEmpty }
}

extension JWS: Codable {
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
self.value = try container.decode(String.self)
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.value)
}
}
8 changes: 0 additions & 8 deletions LocalPackages/PIADashboard/.gitignore

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import StoreKit
import SwiftUI

import struct PIABase.JWS

// MARK: - DebugMenuView

struct ReportResult: Identifiable {
Expand All @@ -16,11 +18,13 @@ public struct DebugMenuView: View {
@State private var logSnapshot: String = ""
@State private var isSendingReport = false
@State private var reportResult: ReportResult? = nil
@State private var isPresentingManageSubscriptions: Bool = false
@State private var isLoadingRefundTransaction = false
@State private var refundTransactionId: UInt64 = 0
@State private var isRefundSheetPresented = false
@State private var availableTransactions: [StoreKit.Transaction] = []
@State private var isTransactionPickerPresented = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — Debug menu exports full StoreKit 2 JWS credential accessible in TestFlight builds

The PIADebugMenu package's DebugMenuView fetches the active entitlement JWS on view appear and renders it with a share-sheet export button: swift .task { entitlementJWS = await Client.store.currentEntitlementJWS() } The "Export" share link in the receiptSection writes the full JWS string to a DebugExportFile (transaction_<timestamp>.txt), and the same value is appended to the "Export All" log bundle under === Transaction (JWS) ===. The debug menu is gated by: ```swift…

View finding in KB

kb · F-A8GCEDDANS1C70XW

@State var entitlementJWS: JWS? = nil
Comment thread
kp-mario-nachbaur marked this conversation as resolved.
Comment thread
kp-mario-nachbaur marked this conversation as resolved.
Comment thread
kp-mario-nachbaur marked this conversation as resolved.

public var body: some View {
mainContent
Expand All @@ -32,6 +36,7 @@ public struct DebugMenuView: View {
)
}
#if os(iOS)
.manageSubscriptionsSheet(isPresented: $isPresentingManageSubscriptions)
.refundRequestSheet(for: refundTransactionId, isPresented: $isRefundSheetPresented) { @MainActor result in
switch result {
case .success(let status):
Expand Down Expand Up @@ -66,6 +71,9 @@ public struct DebugMenuView: View {
.onAppear {
logSnapshot = logs
}
.task {
entitlementJWS = await Client.store.currentEntitlementJWS()
}
.onReceive(Timer.publish(every: 2, on: .main, in: .common).autoconnect()) { _ in
logSnapshot = logs
}
Expand Down Expand Up @@ -157,11 +165,11 @@ public struct DebugMenuView: View {
}

private var receiptSection: some View {
DebugSection("Payment Receipt") {
if let base64 = receiptBase64 {
let preview = String(base64.prefix(300)) + "..."
DebugSection("Transaction (JWS)") {
if let transactionJWS {
let preview = String(transactionJWS.value.prefix(300)) + "..."
VStack(alignment: .leading, spacing: 2) {
Text("Receipt (preview)")
Text("Transaction JWS (preview)")
.font(.caption)
.foregroundStyle(.secondary)
Text(preview)
Expand All @@ -179,16 +187,16 @@ public struct DebugMenuView: View {
#if os(iOS)
Comment thread
kp-mario-nachbaur marked this conversation as resolved.
Comment thread
kp-mario-nachbaur marked this conversation as resolved.
ShareLink(
item: DebugExportFile(
content: base64,
filename: "receipt_\(Int(Date().timeIntervalSince1970)).txt"
content: transactionJWS.value,
filename: "transaction_\(Int(Date().timeIntervalSince1970)).txt"
),
preview: SharePreview("Receipt")
preview: SharePreview("Transaction JWS")
) {
Label("Export", systemImage: "square.and.arrow.up")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — Debug menu exports raw StoreKit 2 JWS receipt via iOS share sheet

DebugMenuView (introduced in this PR as part of the StoreKit 2 migration) displays the current entitlement's JWS representation and exposes it through two iOS share sheet entry points: 1. "Transaction JWS" section – a dedicated ShareLink that exports the raw transactionJWS.value string to a timestamped .txt file: ```swift ShareLink( item: DebugExportFile( content: transactionJWS.value, filename: "transaction_(Int(Date().timeIntervalSince1970)).txt…

View finding in KB

kb · F-XGFXR4MEDSCV754S

}
#endif
} else {
DebugInfoRow(label: "Receipt", value: "Not available")
DebugInfoRow(label: "Transaction", value: "Not available")
}
}
}
Expand Down Expand Up @@ -233,6 +241,9 @@ public struct DebugMenuView: View {

private var subscriptionSection: some View {
DebugSection("Subscription") {
Button("Manage Subscriptions") {
isPresentingManageSubscriptions = true
}
Button {
isLoadingRefundTransaction = true
Task { @MainActor in
Expand Down
Loading
Loading