diff --git a/.github/workflows/multi-account-ci.yml b/.github/workflows/multi-account-ci.yml new file mode 100644 index 000000000..ede59e36d --- /dev/null +++ b/.github/workflows/multi-account-ci.yml @@ -0,0 +1,161 @@ +name: Multi-Account CI + +# Compile-verification CI for the multi-account signing feature. +# +# This project can only be built on macOS (Xcode), so this workflow is the +# authoritative way to verify that the multi-account changes compile and that +# the test targets still build/run. It mirrors the proven build steps from +# pr.yml but is triggered on pushes to the feature branch so every push gets +# a fresh build signal. +# +# The archive build uses CODE_SIGNING_ALLOWED=NO (see the Makefile), so no +# signing secrets are required. + +on: + push: + branches: + - feature/multi-account-support + paths-ignore: + - '**.md' + - 'LICENSE' + - '.editorconfig' + - '.gitignore' + workflow_dispatch: + +concurrency: + group: multi-account-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build (archive, no signing) + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 1 + + - run: brew install ldid xcbeautify + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1.6.0 + with: + xcode-version: "26.2" + + - name: Restore Cache (exact) + id: xcode-cache-exact + uses: actions/cache/restore@v3 + with: + path: | + ~/Library/Developer/Xcode/DerivedData + ~/Library/Caches/org.swift.swiftpm + key: ma-xcode-build-cache-${{ github.ref_name }}-${{ github.sha }} + + - name: Restore Cache (last) + if: steps.xcode-cache-exact.outputs.cache-hit != 'true' + uses: actions/cache/restore@v3 + with: + path: | + ~/Library/Developer/Xcode/DerivedData + ~/Library/Caches/org.swift.swiftpm + key: ma-xcode-build-cache-${{ github.ref_name }}- + + - name: Build + run: | + mkdir -p build/logs + set -o pipefail && NSUnbufferedIO=YES make -B build \ + 2>&1 | tee build/logs/build.log | xcbeautify --renderer github-actions + + - name: Package IPA + run: | + # fakesign + package into an (unsigned) .ipa that can be sideloaded with your own Apple ID. + make fakesign | tee -a build/logs/build.log + # Strip embedded app extensions (the AltWidget appex) before packaging. When sideloading + # with a FREE Apple ID, every embedded extension needs its own App ID + provisioning + # profile; that frequently fails with "The app extension is missing a valid provisioning + # profile." and also burns the free 10-App-IDs-per-week limit. The app and the + # multi-account feature work fine without the home-screen widget. + rm -rf "SideStore.xcarchive/Products/Applications/SideStore.app/PlugIns" + make ipa | tee -a build/logs/build.log + + - name: Save Cache + if: always() + uses: actions/cache/save@v3 + with: + path: | + ~/Library/Developer/Xcode/DerivedData + ~/Library/Caches/org.swift.swiftpm + key: ma-xcode-build-cache-${{ github.ref_name }}-${{ github.sha }} + + - name: Upload build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: multi-account-build-log + path: build/logs/build.log + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: SideStore-multi-account.ipa + path: SideStore.ipa + + unit-tests: + name: Unit tests (DataStructures plan) + runs-on: macos-26 + # NOTE: build-for-testing currently fails on this project — including on the unmodified + # upstream baseline — with "Multiple commands produce .../libem_proxy_static.a" (the app host + # and the test bundle both build the em_proxy SwiftPM static library into the same path). This + # is a pre-existing build-graph issue unrelated to the multi-account feature, so this job is + # allowed to fail without failing the workflow. The "Build (archive, no signing)" job above is + # the authoritative compile signal for the multi-account changes. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 1 + + - run: brew install ldid xcbeautify + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1.6.0 + with: + xcode-version: "26.4" + + - name: Boot simulator + run: | + xcrun simctl boot "iPhone 17 Pro" || true + + - name: Build for testing (DataStructures plan) + run: | + mkdir -p build/logs + set -o pipefail && NSUnbufferedIO=YES xcodebuild build-for-testing \ + -project AltStore.xcodeproj \ + -scheme SideStore \ + -testPlan DataStructureTests \ + -destination 'generic/platform=iOS Simulator' \ + -enableCodeCoverage YES \ + CODE_SIGNING_REQUIRED=NO AD_HOC_CODE_SIGNING_ALLOWED=YES CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM=XYZ0123456 ORG_IDENTIFIER=com.SideStore \ + 2>&1 | tee build/logs/tests-build.log | xcbeautify --renderer github-actions + + - name: Run unit tests (DataStructures plan) + run: | + set -o pipefail && NSUnbufferedIO=YES xcodebuild test-without-building \ + -project AltStore.xcodeproj \ + -scheme SideStore \ + -testPlan DataStructureTests \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -enableCodeCoverage YES \ + CODE_SIGNING_REQUIRED=NO AD_HOC_CODE_SIGNING_ALLOWED=YES CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM=XYZ0123456 ORG_IDENTIFIER=com.SideStore \ + 2>&1 | tee build/logs/tests-run.log | xcbeautify --renderer github-actions + + - name: Upload test logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: multi-account-test-logs + path: build/logs/*.log diff --git a/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme b/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme index 56b21a62c..57d15ed11 100644 --- a/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme +++ b/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme @@ -33,6 +33,9 @@ reference = "container:SideStore/Tests/SideStoreTests.xctestplan" default = "YES"> + + 6.0 CFBundleName $(PRODUCT_NAME) + CFBundleDisplayName + MultiStore CFBundlePackageType APPL CFBundleShortVersionString diff --git a/AltStore/LaunchViewController.swift b/AltStore/LaunchViewController.swift index 5e33a7189..3f7f20b21 100644 --- a/AltStore/LaunchViewController.swift +++ b/AltStore/LaunchViewController.swift @@ -88,9 +88,12 @@ final class LaunchViewController: UIViewController, UIDocumentPickerDelegate { }) alert.addAction(UIAlertAction(title: NSLocalizedString("Select File", comment: ""), style: .default) { _ in - var types = UTType.types(tag: "plist", tagClass: .filenameExtension, conformingTo: nil) - types.append(contentsOf: UTType.types(tag: "mobiledevicepairing", tagClass: .filenameExtension, conformingTo: .data)) - types.append(.xml) + // Accept any file: pairing files exported by external tools come with varied extensions + // (.plist, .mobiledevicepairing, none, …) and iOS sometimes tags a plain .plist in a way + // the narrow type list didn't match, greying it out. `.item` is the universal supertype, + // so every file is selectable; the chosen file is validated when minimuxer starts, so a + // wrong pick just fails gracefully instead of blocking selection. + let types: [UTType] = [.propertyList, .xml, .text, .data, .item] let picker = UIDocumentPickerViewController(forOpeningContentTypes: types) picker.delegate = self picker.shouldShowFileExtensions = true diff --git a/AltStore/Managing Apps/AccountManager+Actions.swift b/AltStore/Managing Apps/AccountManager+Actions.swift new file mode 100644 index 000000000..498af655b --- /dev/null +++ b/AltStore/Managing Apps/AccountManager+Actions.swift @@ -0,0 +1,112 @@ +// +// AccountManager+Actions.swift +// AltStore +// +// App-layer account actions that require the signing/refresh pipeline (AppManager, +// AuthenticationOperation). The data/credential facade lives in +// AltStoreCore/Managers/AccountManager.swift; this extension adds the interactive operations. +// + +import Foundation +import CoreData +import UIKit + +import AltStoreCore +import AltSign + +extension AccountManager +{ + /// Add a new Apple account by presenting the sign-in UI, forcing a fresh login so a *new* + /// Apple ID can be entered rather than silently re-authenticating the current account. The + /// newly added account becomes the default account for new installs. Returns the resolved + /// `Account` (a view-context object) on success. + @discardableResult + func addAccount(presentingViewController: UIViewController, completionHandler: @escaping (Result) -> Void) -> AuthenticationOperation + { + let context = AuthenticatedOperationContext() + context.ignoresCachedCredentials = true + + return AppManager.shared.authenticate(presentingViewController: presentingViewController, context: context, skipDeviceRegistration: false) { result in + switch result + { + case .failure(let error): + completionHandler(.failure(error)) + + case .success(let (team, _, _)): + let accountID = team.account.identifier + DispatchQueue.main.async { + if let account = self.account(accountID, in: DatabaseManager.shared.viewContext) + { + completionHandler(.success(account)) + } + else + { + completionHandler(.failure(OperationError.unknown())) + } + } + } + } + } + + /// Refresh every installed app signed by the given account, authenticating as that account. + @discardableResult + func refreshAccount(_ accountID: String, presentingViewController: UIViewController?, completionHandler: @escaping (Result<[String: Result], Error>) -> Void = { _ in }) -> RefreshGroup + { + let apps = self.appsForAccount(accountID, in: DatabaseManager.shared.viewContext) + + let group = RefreshGroup() + group.context.accountID = accountID + group.completionHandler = { results in + completionHandler(.success(results)) + } + + // Nothing to refresh — complete through the group so any group-based observers finish too. + guard !apps.isEmpty else { + group.completionHandler?([:]) + return group + } + + return AppManager.shared.refresh(apps, presentingViewController: presentingViewController, group: group) + } + + /// Remove an account and its stored credentials on a background context. + func removeAccount(_ accountID: String, completionHandler: @escaping (Result) -> Void) + { + DatabaseManager.shared.persistentContainer.performBackgroundTask { context in + do + { + try self.deleteAccount(accountID, in: context) + DispatchQueue.main.async { completionHandler(.success(())) } + } + catch + { + DispatchQueue.main.async { completionHandler(.failure(error)) } + } + } + } + + /// Reassign an installed app to a different signing account and re-sign it with that account. + func changeSigningAccount(for installedApp: InstalledApp, to accountID: String, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) + { + let bundleIdentifier = installedApp.bundleIdentifier + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + context.performAndWait { + guard self.assignAccount(accountID, toAppWithBundleIdentifier: bundleIdentifier, in: context) else { + DispatchQueue.main.async { completionHandler(.failure(OperationError.appNotFound(name: installedApp.name))) } + return + } + + do { try context.save() } + catch { + DispatchQueue.main.async { completionHandler(.failure(error)) } + return + } + + DispatchQueue.main.async { + // Re-sign with the newly-assigned account (inferred from the app's signingAccountID). + let app = DatabaseManager.shared.viewContext.object(with: installedApp.objectID) as? InstalledApp ?? installedApp + _ = AppManager.shared.resign(app, presentingViewController: presentingViewController, completionHandler: completionHandler) + } + } + } +} diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index ce219dc92..49dd27067 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -755,6 +755,14 @@ extension AppManager } let group = RefreshGroup(context: context) + + // Update re-signs the app, so authenticate with the account that currently signs it + // (the operation carries the new AppVersion, so this can't be inferred in perform()). + if group.context.accountID == nil + { + group.context.accountID = self.inferredAccountID(for: [.refresh(installedApp)]) + } + group.completionHandler = { (results) in do { @@ -766,7 +774,7 @@ extension AppManager completionHandler(.failure(error)) } } - + assert(appVersion as AnyObject !== installedApp) // Make sure we never accidentally "update" to already installed app. Task{ @@ -783,19 +791,146 @@ extension AppManager @discardableResult func refresh(_ installedApps: [InstalledApp], presentingViewController: UIViewController?, group: RefreshGroup? = nil) -> RefreshGroup { - let group = group ?? RefreshGroup() - - Task{ - do { - try await self.perform(installedApps.map { .refresh($0) }, presentingViewController: presentingViewController, group: group) - } catch { - group.context.error = error - let results = Dictionary(uniqueKeysWithValues: installedApps.map { ($0.bundleIdentifier, Result.failure(error)) }) - group.completionHandler?(results) + let aggregateGroup = group ?? RefreshGroup() + + Task { + // Group the apps by the Apple account that signs each one, so every app is refreshed + // with the account that originally installed it. This is the core of multi-account + // refresh: each account is authenticated and refreshed in its own group/context, so a + // failure for one account (bad credentials, revoked cert, etc.) only fails that + // account's apps — the others keep refreshing (failure isolation). + let partitions = await self.partitionAppsByAccount(installedApps) + + // Fast path: a single account (the common case, and every existing single-account + // install) behaves exactly as before — one group, one authentication. + if partitions.count <= 1 + { + if let accountID = partitions.first?.accountID, + aggregateGroup.context.accountID == nil, + Keychain.shared.hasCredentials(forAccount: accountID) + { + // Authenticate this specific account via its per-account credentials. + aggregateGroup.context.accountID = accountID + } + + do { + try await self.perform(installedApps.map { .refresh($0) }, presentingViewController: presentingViewController, group: aggregateGroup) + } catch { + aggregateGroup.context.error = error + let results = Dictionary(uniqueKeysWithValues: installedApps.map { ($0.bundleIdentifier, Result.failure(error)) }) + aggregateGroup.completionHandler?(results) + } + return + } + + // Multiple accounts: fan out into one child group per account and aggregate. + self.refresh(partitions: partitions, presentingViewController: presentingViewController, aggregateGroup: aggregateGroup) + } + + return aggregateGroup + } + + /// Partition installed apps by their resolved signing account identifier, preserving order. + /// Apps with no explicit signing account fall back to the default (active) account so they + /// are grouped together rather than each spawning a separate authentication. + private func partitionAppsByAccount(_ apps: [InstalledApp]) async -> [(accountID: String?, apps: [InstalledApp])] + { + let defaultAccountID = await DatabaseManager.shared.persistentContainer.performBackgroundTask { context in + DatabaseManager.shared.activeAccount(in: context)?.identifier + } + + var order = [String?]() + var buckets = [String?: [InstalledApp]]() + + for app in apps + { + var resolvedID: String? + if let context = app.managedObjectContext + { + context.performAndWait { resolvedID = app.resolvedSigningAccountID } + } + else + { + resolvedID = app.resolvedSigningAccountID + } + + let key = resolvedID ?? defaultAccountID + if buckets[key] == nil + { + buckets[key] = [] + order.append(key) + } + buckets[key]?.append(app) + } + + return order.map { (accountID: $0, apps: buckets[$0] ?? []) } + } + + /// Run each account's apps in its own authenticated `RefreshGroup`, merging results, progress + /// and installation callbacks back into `aggregateGroup`. Each child authenticates + /// independently, so a failure in one account is isolated to that account's apps. + private func refresh(partitions: [(accountID: String?, apps: [InstalledApp])], presentingViewController: UIViewController?, aggregateGroup: RefreshGroup) + { + let lock = NSLock() + var remaining = partitions.count + var didComplete = false + var merged = [String: Result]() + + func childFinished(_ results: [String: Result]) + { + lock.lock() + for (bundleID, result) in results + { + merged[bundleID] = result + aggregateGroup.set(result, forAppWithBundleIdentifier: bundleID) + } + remaining -= 1 + let isDone = (remaining <= 0) && !didComplete + if isDone { didComplete = true } + let snapshot = merged + lock.unlock() + + // Fire the aggregate completion exactly once — a background refresh resumes a + // continuation here, so a double-invocation would be fatal. + if isDone + { + aggregateGroup.completionHandler?(snapshot) + } + } + + for partition in partitions + { + let childGroup = RefreshGroup() + childGroup.context.accountID = partition.accountID + + // Forward the SideStore self-install callback (used for the background-refresh + // notification) up to the aggregate group. + childGroup.beginInstallationHandler = { [weak aggregateGroup, weak childGroup] installedApp in + if let error = childGroup?.context.error + { + aggregateGroup?.context.error = error + } + aggregateGroup?.beginInstallationHandler?(installedApp) + } + + childGroup.completionHandler = { results in + childFinished(results) + } + + aggregateGroup.progress.totalUnitCount += 1 + aggregateGroup.progress.addChild(childGroup.progress, withPendingUnitCount: 1) + + let apps = partition.apps + Task { + do { + try await self.perform(apps.map { .refresh($0) }, presentingViewController: presentingViewController, group: childGroup) + } catch { + childGroup.context.error = error + let results = Dictionary(uniqueKeysWithValues: apps.map { ($0.bundleIdentifier, Result.failure(error)) }) + childGroup.completionHandler?(results) + } } } - - return group } func activate(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) @@ -1128,6 +1263,40 @@ private extension AppManager } @discardableResult + /// The signing account to authenticate for a batch of operations, inferred from their apps. + /// + /// Returns an account identifier only when every installed-app operation resolves to the *same* + /// account and that account has usable stored credentials; otherwise `nil` (fall back to the + /// default/global credentials). Operations whose app isn't an `InstalledApp` (e.g. installing a + /// brand-new app) are ignored, so new installs continue to use the default account. + private func inferredAccountID(for operations: [AppOperation]) -> String? + { + var accountIDs = Set() + + for operation in operations + { + guard let installedApp = operation.app as? InstalledApp else { continue } + + var resolvedID: String? + if let context = installedApp.managedObjectContext + { + context.performAndWait { resolvedID = installedApp.resolvedSigningAccountID } + } + else + { + resolvedID = installedApp.resolvedSigningAccountID + } + + if let resolvedID = resolvedID + { + accountIDs.insert(resolvedID) + } + } + + guard accountIDs.count == 1, let accountID = accountIDs.first, Keychain.shared.hasCredentials(forAccount: accountID) else { return nil } + return accountID + } + private func perform(_ operations: [AppOperation], presentingViewController: UIViewController?, group: RefreshGroup) async throws -> RefreshGroup { let operations = operations.filter { self.progress(for: $0) == nil || self.progress(for: $0)?.isCancelled == true } @@ -1142,7 +1311,16 @@ private extension AppManager { group.context.presentingViewController = viewController } - + + // If no specific account was requested (and we still need to authenticate), infer the + // signing account from the operations' apps so single-app actions (resign / refresh / + // activate / …) authenticate with — and re-sign using — the account that actually signs + // the app rather than the default account. + if group.context.accountID == nil, group.context.session == nil + { + group.context.accountID = self.inferredAccountID(for: operations) + } + /* Authenticate (if necessary) */ var authenticationOperation: AuthenticationOperation? if group.context.session == nil diff --git a/AltStore/Operations/AuthenticationOperation.swift b/AltStore/Operations/AuthenticationOperation.swift index 825f4b08e..06a766ee5 100644 --- a/AltStore/Operations/AuthenticationOperation.swift +++ b/AltStore/Operations/AuthenticationOperation.swift @@ -93,11 +93,11 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate?, AltSign.setLogging(OperationsLoggingControl.getFromDatabase(for: AuthenticationOperation.self)) Task { - // try to use cached session - if - let certificate = Keychain.shared.certificate, - let session = Keychain.shared.session, - let team = Keychain.shared.team { + // try to use cached session (per-account when a specific account is targeted) + if !self.context.ignoresCachedCredentials, let cached = self.loadCachedAuthState() { + let certificate = cached.certificate + let session = cached.session + let team = cached.team if session.anisetteData.date.timeIntervalSinceNow < -40.0 { do { let anisetteData = try await withCheckedThrowingContinuation { (c: CheckedContinuation) in @@ -165,9 +165,8 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate?, guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) } try await self.cacheAppIDs(team: team, session: session) - Keychain.shared.team = team - Keychain.shared.certificate = certificate - Keychain.shared.session = session + let resolvedAccountID = self.context.accountID ?? team.account.identifier + self.cacheAuthState(session: session, certificate: certificate, team: team, accountID: resolvedAccountID) self.finish(.success((team, certificate, session))) } catch { @@ -233,63 +232,69 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate?, let account = Account.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Account.identifier), altTeam.account.identifier), in: context), let team = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), altTeam.identifier), in: context) else { throw AuthenticationError(.noTeam) } - // Account - account.isActiveAccount = true - - let otherAccountsFetchRequest = Account.fetchRequest() as NSFetchRequest - otherAccountsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Account.identifier), account.identifier) - - let otherAccounts = try context.fetch(otherAccountsFetchRequest) - for account in otherAccounts { - account.isActiveAccount = false - } - - // Team - team.isActiveTeam = true - - let otherTeamsFetchRequest = Team.fetchRequest() as NSFetchRequest - otherTeamsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Team.identifier), team.identifier) - - let otherTeams = try context.fetch(otherTeamsFetchRequest) - for team in otherTeams { - team.isActiveTeam = false - } - let isSparseRestorePatched = ProcessInfo().sparseRestorePatched - let isAppLimitDisabled = UserDefaults.standard.isAppLimitDisabled + let resolvedAccountID = self.context.accountID ?? altTeam.account.identifier + + // Only (re)designate the default account/team when this is an interactive/default + // authentication (accountID == nil, e.g. sign-in or "add account"). A targeted refresh + // of a specific account must NOT change which account is the default for new installs, + // nor deactivate the other accounts — they must keep refreshing independently. + if self.context.accountID == nil { + // Account + account.isActiveAccount = true + + let otherAccountsFetchRequest = Account.fetchRequest() as NSFetchRequest + otherAccountsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Account.identifier), account.identifier) - UserDefaults.standard.activeAppsLimit = nil - // TODO: @mahee96: is the minimum ver match for ios 13.3.1 check required? - // if so what is the app limit? As nil app limit specifies unlimited apps?! - if team.type == .free { - if (!isAppLimitDisabled && isSparseRestorePatched) || - (isAppLimitDisabled && !isSparseRestorePatched) { - UserDefaults.standard.activeAppsLimit = InstalledApp.freeAccountActiveAppsLimit + let otherAccounts = try context.fetch(otherAccountsFetchRequest) + for account in otherAccounts { + account.isActiveAccount = false + } + + // Team + team.isActiveTeam = true + + let otherTeamsFetchRequest = Team.fetchRequest() as NSFetchRequest + otherTeamsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Team.identifier), team.identifier) + + let otherTeams = try context.fetch(otherTeamsFetchRequest) + for team in otherTeams { + team.isActiveTeam = false + } + + let isSparseRestorePatched = ProcessInfo().sparseRestorePatched + let isAppLimitDisabled = UserDefaults.standard.isAppLimitDisabled + + UserDefaults.standard.activeAppsLimit = nil + // TODO: @mahee96: is the minimum ver match for ios 13.3.1 check required? + // if so what is the app limit? As nil app limit specifies unlimited apps?! + if team.type == .free { + if (!isAppLimitDisabled && isSparseRestorePatched) || + (isAppLimitDisabled && !isSparseRestorePatched) { + UserDefaults.standard.activeAppsLimit = InstalledApp.freeAccountActiveAppsLimit + } } } - + // Save try context.save() - - // Update keychain - Keychain.shared.appleIDEmailAddress = self.appleIDEmailAddress ?? altTeam.account.appleID // Prefer the user's provided email address over the one associated with their account (which may be outdated). - if let appleIDPassword = self.appleIDPassword { - Keychain.shared.appleIDPassword = appleIDPassword - } - + + // Persist credentials for the resolved account (mirrored to global for the default). + let emailAddress = self.appleIDEmailAddress ?? altTeam.account.appleID // Prefer the user's provided email address over the one associated with their account (which may be outdated). + self.persistLoginCredentials(emailAddress: emailAddress, password: self.appleIDPassword, adsid: session.dsid, xcodeToken: session.authToken, accountID: resolvedAccountID) + if let altCertificate = altCertificate, !self.skipCertificateProvisioning { Task { let didShowInstructions = await self.showInstructionsIfNecessary() - + let signer = ALTSigner(team: altTeam, certificate: altCertificate) AltSign.setLogging(OperationsLoggingControl.getFromDatabase(for: AuthenticationOperation.self)) // Refresh screen must go last since a successful refresh will cause the app to quit. let didShowRefreshAlert = await self.showRefreshScreenIfNecessary(signer: signer, session: session) if !didShowRefreshAlert { - Keychain.shared.signingCertificate = altCertificate.p12Data() - Keychain.shared.signingCertificatePassword = altCertificate.machineIdentifier + self.persistSigningCertificate(altCertificate, accountID: resolvedAccountID) } - + await MainActor.run { super.finish(result) self.navigationController.dismiss(animated: true, completion: nil) @@ -355,7 +360,11 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate?, } private func signIn() async throws -> (ALTAccount, ALTAppleAPISession) { - if let adsid = Keychain.shared.appleIDAdsid, let xcodeToken = Keychain.shared.appleIDXcodeToken { + // "Add Account" forces a fresh sign-in so a new Apple ID can be entered rather than + // silently re-authenticating the existing account's stored credentials. + let credentials = self.context.ignoresCachedCredentials ? AccountCredentials() : self.storedCredentials() + + if let adsid = credentials.adsid, let xcodeToken = credentials.xcodeToken { self.verboseLog("Authenticating Apple ID with tokens...") do { let (account, session) = try await self.authenticateWithToken(adsid: adsid, xcodeToken: xcodeToken) @@ -364,8 +373,8 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate?, self.debugLog("Authentication failed with token. Fall back to email and password login: \(error)") } } - - if let appleID = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword { + + if let appleID = credentials.emailAddress, let password = credentials.password { self.debugLog("Authenticating Apple ID...") do { @@ -489,8 +498,7 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate?, ALTAppleAPI.shared.authenticate(appleID: appleID, password: password, anisetteData: anisetteData, verificationHandler: verificationHandler) { (account, session, error) in if let account = account, let session = session { - Keychain.shared.appleIDAdsid = session.dsid - Keychain.shared.appleIDXcodeToken = session.authToken + self.persistLoginTokens(adsid: session.dsid, xcodeToken: session.authToken) continuation.resume(returning: (account, session)) } else { continuation.resume(throwing: error ?? OperationError.unknown()) @@ -501,15 +509,25 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate?, private func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession) async throws -> ALTTeam { let teams = try await ALTAppleAPI.shared.fetchTeams(for: account, session: session) - - let activeTeamFromDB = await DatabaseManager.shared.persistentContainer.performBackgroundTask { context in - DatabaseManager.shared.activeTeam(in: context) + + let targetAccountID = self.context.accountID + let preferredTeamID = await DatabaseManager.shared.persistentContainer.performBackgroundTask { context -> String? in + if let targetAccountID = targetAccountID { + // When authenticating a specific account, prefer one of *its* teams so a + // different account's active team doesn't shadow it (and so multi-team accounts + // resolve without needing UI during a headless refresh). + let account = AccountManager.shared.account(targetAccountID, in: context) + let team = account?.teams.first(where: { $0.isActiveTeam }) ?? account?.teams.first + return team?.identifier ?? DatabaseManager.shared.activeTeam(in: context)?.identifier + } else { + return DatabaseManager.shared.activeTeam(in: context)?.identifier + } } - - if let activeTeam = activeTeamFromDB, let altTeam = teams.first(where: { $0.identifier == activeTeam.identifier }) { + + if let preferredTeamID = preferredTeamID, let altTeam = teams.first(where: { $0.identifier == preferredTeamID }) { return altTeam } - + return try await self.selectTeam(from: teams) } @@ -809,11 +827,113 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate?, extension AuthenticationOperation { @objc func textFieldTextDidChange(_ notification: Notification) { guard let textField = notification.object as? UITextField else { return } - + self.submitCodeAction?.isEnabled = (textField.text ?? "").count == 6 } } +// MARK: - Per-account credential storage +// +// When `context.accountID` is set, credentials and the cached session are stored/loaded from +// that account's isolated slots so multiple accounts can stay authenticated at once. When it is +// nil (the interactive "sign in / add account" flow and the default account), the legacy global +// keychain slots are used, and — once the account identifier is known — the values are also +// mirrored into the per-account slots so the default account participates in per-account refresh. +private extension AuthenticationOperation { + var targetAccountID: String? { self.context.accountID } + + /// The cached authenticated state for the account being authenticated, if still in memory. + func loadCachedAuthState() -> (certificate: ALTCertificate, session: ALTAppleAPISession, team: ALTTeam)? { + if let accountID = self.targetAccountID { + guard let certificate = Keychain.shared.cachedCertificate(forAccount: accountID), + let session = Keychain.shared.cachedSession(forAccount: accountID), + let team = Keychain.shared.cachedTeam(forAccount: accountID) else { return nil } + return (certificate, session, team) + } else { + guard let certificate = Keychain.shared.certificate, + let session = Keychain.shared.session, + let team = Keychain.shared.team else { return nil } + return (certificate, session, team) + } + } + + /// The stored login credentials for the account being authenticated. + func storedCredentials() -> AccountCredentials { + if let accountID = self.targetAccountID { + return Keychain.shared.credentials(forAccount: accountID) + } else { + return AccountCredentials( + emailAddress: Keychain.shared.appleIDEmailAddress, + password: Keychain.shared.appleIDPassword, + adsid: Keychain.shared.appleIDAdsid, + xcodeToken: Keychain.shared.appleIDXcodeToken, + signingCertificate: Keychain.shared.signingCertificate, + signingCertificatePassword: Keychain.shared.signingCertificatePassword + ) + } + } + + /// Cache the authenticated session/certificate/team for `accountID` (and mirror to the global + /// cache for the default/interactive flow so legacy code keeps working). + func cacheAuthState(session: ALTAppleAPISession, certificate: ALTCertificate?, team: ALTTeam, accountID: String) { + Keychain.shared.cache(session: session, certificate: certificate, team: team, forAccount: accountID) + + if self.targetAccountID == nil { + Keychain.shared.session = session + Keychain.shared.certificate = certificate + Keychain.shared.team = team + } + } + + /// Persist the login tokens obtained during interactive authentication. + func persistLoginTokens(adsid: String, xcodeToken: String) { + if let accountID = self.targetAccountID { + var credentials = Keychain.shared.credentials(forAccount: accountID) + credentials.adsid = adsid + credentials.xcodeToken = xcodeToken + Keychain.shared.setCredentials(credentials, forAccount: accountID) + } else { + Keychain.shared.appleIDAdsid = adsid + Keychain.shared.appleIDXcodeToken = xcodeToken + } + } + + /// Persist the Apple ID + password + session tokens for the resolved account (mirrored to + /// global for the default). Storing the dsid/authToken per-account lets that account later + /// re-authenticate silently via token even when it was signed in through the global flow. + func persistLoginCredentials(emailAddress: String, password: String?, adsid: String?, xcodeToken: String?, accountID: String) { + var credentials = Keychain.shared.credentials(forAccount: accountID) + credentials.emailAddress = emailAddress + if let password = password { credentials.password = password } + if let adsid = adsid { credentials.adsid = adsid } + if let xcodeToken = xcodeToken { credentials.xcodeToken = xcodeToken } + Keychain.shared.setCredentials(credentials, forAccount: accountID) + + if self.targetAccountID == nil { + Keychain.shared.appleIDEmailAddress = emailAddress + if let password = password { + Keychain.shared.appleIDPassword = password + } + } + } + + /// Persist the signing certificate for the resolved account (mirrored to global for default). + func persistSigningCertificate(_ certificate: ALTCertificate, accountID: String) { + let p12Data = certificate.p12Data() + let password = certificate.machineIdentifier + + var credentials = Keychain.shared.credentials(forAccount: accountID) + credentials.signingCertificate = p12Data + credentials.signingCertificatePassword = password + Keychain.shared.setCredentials(credentials, forAccount: accountID) + + if self.targetAccountID == nil { + Keychain.shared.signingCertificate = p12Data + Keychain.shared.signingCertificatePassword = password + } + } +} + extension ALTAppleAPI { func fetchAccount2(session: ALTAppleAPISession, completionHandler: @escaping (Result) -> Void) { diff --git a/AltStore/Operations/Common/OperationContexts.swift b/AltStore/Operations/Common/OperationContexts.swift index 384cb5526..feacf8f6b 100644 --- a/AltStore/Operations/Common/OperationContexts.swift +++ b/AltStore/Operations/Common/OperationContexts.swift @@ -41,19 +41,34 @@ class OperationContext final class AuthenticatedOperationContext: OperationContext { var session: ALTAppleAPISession? - + var team: ALTTeam? var certificate: ALTCertificate? - + + /// Identifier (`Account.identifier`) of the Apple account this context should authenticate as. + /// + /// When set, `AuthenticationOperation` loads/stores credentials and the cached session for + /// this specific account, allowing multiple accounts to be authenticated simultaneously with + /// isolated state. When `nil`, it behaves as the legacy single-account flow (global keychain, + /// interactive sign-in), which is used for the default account and the "add account" UI. + var accountID: String? + + /// When `true`, `AuthenticationOperation` ignores any cached session and stored credentials and + /// forces a fresh interactive sign-in. Used by "Add Account" so a *new* Apple ID can be entered + /// instead of silently re-authenticating the existing default account. + var ignoresCachedCredentials: Bool = false + weak var authenticationOperation: AuthenticationOperation? - + convenience init(context: AuthenticatedOperationContext) { self.init(error: context.error, operations: context.operations.allObjects) - + self.session = context.session self.team = context.team self.certificate = context.certificate + self.accountID = context.accountID + self.ignoresCachedCredentials = context.ignoresCachedCredentials self.authenticationOperation = context.authenticationOperation } } diff --git a/AltStore/Operations/InstallAppOperation.swift b/AltStore/Operations/InstallAppOperation.swift index dd845ecff..f5cb6f031 100644 --- a/AltStore/Operations/InstallAppOperation.swift +++ b/AltStore/Operations/InstallAppOperation.swift @@ -149,9 +149,18 @@ final class InstallAppOperation: ResultOperation, OperationLogging installedApp.useMainProfile = self.context.useMainProfile installedApp.needsResign = false - - if let team = DatabaseManager.shared.activeTeam(in: backgroundContext) { + + // Bind the app to the account/team that actually signed it (carried on the authenticated + // context) and permanently record the signing account, so every future refresh + // authenticates with — and re-signs using — the correct Apple account. Fall back to the + // default (active) team only if the context somehow lacks a team. + if let signingTeam = self.context.team, + let team = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), signingTeam.identifier), in: backgroundContext) { + installedApp.team = team + installedApp.signingAccountID = team.account?.identifier ?? signingTeam.account.identifier + } else if let team = DatabaseManager.shared.activeTeam(in: backgroundContext) { installedApp.team = team + installedApp.signingAccountID = team.account?.identifier } return installedApp diff --git a/AltStore/Operations/ResignAppOperation.swift b/AltStore/Operations/ResignAppOperation.swift index 6815eda7a..9f6bf277c 100644 --- a/AltStore/Operations/ResignAppOperation.swift +++ b/AltStore/Operations/ResignAppOperation.swift @@ -139,10 +139,20 @@ final class ResignAppOperation: ResultOperation, OperationLoggin additionalValues[Bundle.Info.deviceID] = udid additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID - let data = Keychain.shared.signingCertificate - let signingCertificate = data.flatMap { (try? ALTCertificate(p12Data: $0, password: "")) ?? (try? ALTCertificate(p12Data: $0, password: nil)) } - let encryptingPassword = Keychain.shared.signingCertificatePassword - + // Embed the certificate SideStore should use to re-sign itself in the background. + // Prefer the certificate of the account that is signing this refresh (multi-account), + // falling back to the global default certificate for backwards compatibility. + let signingCertificate: ALTCertificate? + let encryptingPassword: String? + if let contextCertificate = self.context.certificate, let machineIdentifier = contextCertificate.machineIdentifier { + signingCertificate = contextCertificate + encryptingPassword = machineIdentifier + } else { + let data = Keychain.shared.signingCertificate + signingCertificate = data.flatMap { (try? ALTCertificate(p12Data: $0, password: "")) ?? (try? ALTCertificate(p12Data: $0, password: nil)) } + encryptingPassword = Keychain.shared.signingCertificatePassword + } + if let signingCertificate = signingCertificate, let encryptingPassword = encryptingPassword { diff --git a/AltStore/Resources/ReleaseEntitlements.plist b/AltStore/Resources/ReleaseEntitlements.plist index 8b3ff4bd2..27cf0aeef 100644 --- a/AltStore/Resources/ReleaseEntitlements.plist +++ b/AltStore/Resources/ReleaseEntitlements.plist @@ -3,7 +3,7 @@ application-identifier - XYZ0123456.com.SideStore.SideStore + XYZ0123456.com.SideStore.MultiStore aps-environment development com.apple.developer.siri @@ -12,7 +12,7 @@ XYZ0123456 com.apple.security.application-groups - group.com.SideStore.SideStore + group.com.SideStore.MultiStore get-task-allow diff --git a/AltStore/Settings/Accounts/AccountAppsViewController.swift b/AltStore/Settings/Accounts/AccountAppsViewController.swift new file mode 100644 index 000000000..e66529ea0 --- /dev/null +++ b/AltStore/Settings/Accounts/AccountAppsViewController.swift @@ -0,0 +1,143 @@ +// +// AccountAppsViewController.swift +// AltStore +// +// Lists the installed apps signed by a given account and lets the user reassign an app to a +// different account (which re-signs it). Provides the "change signing account" capability +// without modifying the existing storyboard-driven app detail screen. +// + +import UIKit + +import AltStoreCore +import AltSign + +class AccountAppsViewController: UITableViewController +{ + private let accountID: String + private var apps: [InstalledApp] = [] + + init(accountID: String) + { + self.accountID = accountID + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) + { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.title = NSLocalizedString("Signed Apps", comment: "") + self.reloadApps() + } + + private func reloadApps() + { + self.apps = AccountManager.shared.appsForAccount(self.accountID, in: DatabaseManager.shared.viewContext) + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + if self.isViewLoaded + { + self.tableView.reloadData() + } + } + + override func numberOfSections(in tableView: UITableView) -> Int + { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int + { + return max(self.apps.count, 1) + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? + { + return NSLocalizedString("Tap an app to sign it with a different account. Changing the account re-signs the app.", comment: "") + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell + { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + + guard !self.apps.isEmpty else + { + cell.textLabel?.text = NSLocalizedString("No apps signed by this account", comment: "") + cell.textLabel?.textColor = .secondaryLabel + cell.selectionStyle = .none + return cell + } + + let app = self.apps[indexPath.row] + cell.textLabel?.text = app.name + cell.detailTextLabel?.text = app.bundleIdentifier + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) + { + tableView.deselectRow(at: indexPath, animated: true) + guard !self.apps.isEmpty else { return } + + let app = self.apps[indexPath.row] + self.presentAccountPicker(for: app, sourceView: tableView.cellForRow(at: indexPath)) + } + + private func presentAccountPicker(for app: InstalledApp, sourceView: UIView?) + { + let candidates = AccountManager.shared.listAccounts(in: DatabaseManager.shared.viewContext) + .filter { $0.identifier != self.accountID } + + guard !candidates.isEmpty else + { + let alertController = UIAlertController( + title: NSLocalizedString("No Other Accounts", comment: ""), + message: NSLocalizedString("Add another Apple account before changing an app's signing account.", comment: ""), + preferredStyle: .alert + ) + alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default)) + self.present(alertController, animated: true) + return + } + + let alertController = UIAlertController( + title: String(format: NSLocalizedString("Sign “%@” with…", comment: ""), app.name), + message: nil, + preferredStyle: .actionSheet + ) + + for account in candidates + { + let accountID = account.identifier + alertController.addAction(UIAlertAction(title: account.localizedName, style: .default) { [weak self] _ in + self?.changeAccount(for: app, to: accountID) + }) + } + + alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel)) + alertController.popoverPresentationController?.sourceView = sourceView + alertController.popoverPresentationController?.sourceRect = sourceView?.bounds ?? .zero + self.present(alertController, animated: true) + } + + private func changeAccount(for app: InstalledApp, to accountID: String) + { + AccountManager.shared.changeSigningAccount(for: app, to: accountID, presentingViewController: self) { [weak self] result in + DispatchQueue.main.async { + if case .failure(let error) = result, !(error is CancellationError) + { + let alertController = UIAlertController(title: NSLocalizedString("Couldn't Change Account", comment: ""), message: error.localizedDescription, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default)) + self?.present(alertController, animated: true) + } + self?.reloadApps() + } + } + } +} diff --git a/AltStore/Settings/Accounts/AccountsViewController.swift b/AltStore/Settings/Accounts/AccountsViewController.swift new file mode 100644 index 000000000..59ee32ed8 --- /dev/null +++ b/AltStore/Settings/Accounts/AccountsViewController.swift @@ -0,0 +1,251 @@ +// +// AccountsViewController.swift +// AltStore +// +// Minimal UI for managing multiple Apple accounts and each app's signing account. +// +// Deliberately implemented programmatically and self-contained so it doesn't require changes to +// the existing storyboard-driven screens. Backend behaviour (AccountManager) is the source of +// truth; this screen is a thin presentation layer over it. +// + +import UIKit + +import AltStoreCore +import AltSign + +class AccountsViewController: UITableViewController +{ + private var accounts: [Account] = [] + + init() + { + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) + { + super.init(coder: coder) + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.title = NSLocalizedString("Apple Accounts", comment: "") + + self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(AccountsViewController.done)) + self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(AccountsViewController.addAccount)) + + self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + self.reloadAccounts() + } + + private func reloadAccounts() + { + self.accounts = AccountManager.shared.listAccounts(in: DatabaseManager.shared.viewContext) + if self.isViewLoaded + { + self.tableView.reloadData() + } + } + + @objc private func done() + { + self.dismiss(animated: true) + } + + @objc private func addAccount() + { + AccountManager.shared.addAccount(presentingViewController: self) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result + { + case .success: self.reloadAccounts() + case .failure(OperationError.cancelled): break + case .failure(let error): self.present(error: error) + } + } + } + } +} + +// MARK: - Table view +extension AccountsViewController +{ + override func numberOfSections(in tableView: UITableView) -> Int + { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int + { + return max(self.accounts.count, 1) + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? + { + return NSLocalizedString("SIGNED-IN ACCOUNTS", comment: "") + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? + { + return NSLocalizedString("Each installed app is refreshed with the account that signed it. Tap an account to set it as the default for new installs, refresh its apps, or change which apps it signs.", comment: "") + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell + { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + + guard !self.accounts.isEmpty else + { + cell.textLabel?.text = NSLocalizedString("No accounts", comment: "") + cell.detailTextLabel?.text = NSLocalizedString("Tap + to add an Apple account.", comment: "") + cell.textLabel?.textColor = .secondaryLabel + cell.selectionStyle = .none + return cell + } + + let account = self.accounts[indexPath.row] + + var title = account.localizedName + if title.trimmingCharacters(in: .whitespaces).isEmpty + { + title = account.appleID + } + if account.isActiveAccount + { + title += " " + NSLocalizedString("(Default)", comment: "") + } + cell.textLabel?.text = title + + let status = AccountManager.shared.hasValidCredentials(for: account) + ? NSLocalizedString("Signed in", comment: "") + : NSLocalizedString("Needs sign-in", comment: "") + cell.detailTextLabel?.text = "\(account.appleID) · \(status)" + cell.detailTextLabel?.textColor = AccountManager.shared.hasValidCredentials(for: account) ? .secondaryLabel : .systemRed + + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) + { + tableView.deselectRow(at: indexPath, animated: true) + guard !self.accounts.isEmpty else { return } + + let account = self.accounts[indexPath.row] + self.presentActions(for: account, sourceView: tableView.cellForRow(at: indexPath)) + } + + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? + { + guard !self.accounts.isEmpty else { return nil } + let account = self.accounts[indexPath.row] + + let removeAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Remove", comment: "")) { [weak self] _, _, completion in + self?.confirmRemove(account) + completion(true) + } + return UISwipeActionsConfiguration(actions: [removeAction]) + } +} + +// MARK: - Actions +private extension AccountsViewController +{ + func presentActions(for account: Account, sourceView: UIView?) + { + let accountID = account.identifier + let alertController = UIAlertController(title: account.localizedName, message: account.appleID, preferredStyle: .actionSheet) + + if !account.isActiveAccount + { + alertController.addAction(UIAlertAction(title: NSLocalizedString("Set as Default", comment: ""), style: .default) { [weak self] _ in + self?.setDefault(accountID) + }) + } + + alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh Apps", comment: ""), style: .default) { [weak self] _ in + self?.refresh(accountID) + }) + + alertController.addAction(UIAlertAction(title: NSLocalizedString("Manage Signed Apps", comment: ""), style: .default) { [weak self] _ in + let appsViewController = AccountAppsViewController(accountID: accountID) + self?.navigationController?.pushViewController(appsViewController, animated: true) + }) + + alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove Account", comment: ""), style: .destructive) { [weak self] _ in + self?.confirmRemove(account) + }) + + alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel)) + + alertController.popoverPresentationController?.sourceView = sourceView + alertController.popoverPresentationController?.sourceRect = sourceView?.bounds ?? .zero + self.present(alertController, animated: true) + } + + func setDefault(_ accountID: String) + { + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + context.performAndWait { + AccountManager.shared.setDefaultAccount(accountID, in: context) + do { try context.save() } + catch { debugLog("Failed to set default account: \(error)") } + } + self.reloadAccounts() + } + + func refresh(_ accountID: String) + { + AccountManager.shared.refreshAccount(accountID, presentingViewController: self) { [weak self] result in + DispatchQueue.main.async { + switch result + { + case .success(let results): + let failures = results.values.filter { if case .failure = $0 { return true } else { return false } } + if let firstFailure = failures.first, case .failure(let error) = firstFailure + { + self?.present(error: error) + } + case .failure(let error): + self?.present(error: error) + } + self?.reloadAccounts() + } + } + } + + func confirmRemove(_ account: Account) + { + let accountID = account.identifier + let alertController = UIAlertController( + title: String(format: NSLocalizedString("Remove “%@”?", comment: ""), account.localizedName), + message: NSLocalizedString("Its stored credentials will be deleted. Apps signed by this account will stop refreshing until you reassign them or sign in again.", comment: ""), + preferredStyle: .alert + ) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel)) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove", comment: ""), style: .destructive) { [weak self] _ in + AccountManager.shared.removeAccount(accountID) { result in + DispatchQueue.main.async { + if case .failure(let error) = result + { + self?.present(error: error) + } + self?.reloadAccounts() + } + } + }) + self.present(alertController, animated: true) + } + + func present(error: Error) + { + let alertController = UIAlertController(title: NSLocalizedString("Operation Failed", comment: ""), message: error.localizedDescription, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default)) + self.present(alertController, animated: true) + } +} diff --git a/AltStore/Settings/SettingsViewController.swift b/AltStore/Settings/SettingsViewController.swift index dfae5bcf3..505af211e 100644 --- a/AltStore/Settings/SettingsViewController.swift +++ b/AltStore/Settings/SettingsViewController.swift @@ -265,10 +265,26 @@ final class SettingsViewController: UITableViewController } configureReleaseChannelButton() + + // Multi-account: entry point to manage all signed-in Apple accounts. + self.navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "person.2.circle"), + style: .plain, + target: self, + action: #selector(SettingsViewController.showAccounts(_:)) + ) + #if !targetEnvironment(simulator) detectAndImportAccountFile() #endif } + + @objc func showAccounts(_ sender: Any) + { + let accountsViewController = AccountsViewController() + let navigationController = UINavigationController(rootViewController: accountsViewController) + self.present(navigationController, animated: true) + } func importAccountAtFile(_ file: URL, remove: Bool = false) { _ = file.startAccessingSecurityScopedResource() @@ -1105,9 +1121,17 @@ extension SettingsViewController } + if section == .account + { + // The account rows open the multi-account management screen — show a chevron so it's + // clear they're tappable (add / remove / switch Apple accounts). + cell.accessoryType = .disclosureIndicator + } + + return cell } - + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let section = Section.allCases[section] @@ -1710,7 +1734,10 @@ extension SettingsViewController // case .account, .patreon, .display, .instructions, .macDirtyCow: break - case .account, .patreon, .display, .instructions, .betaTesting: break + case .account: + // Tapping the signed-in account opens the multi-account management screen. + self.showAccounts(self) + case .patreon, .display, .instructions, .betaTesting: break } diff --git a/AltStoreCore/Components/Keychain.swift b/AltStoreCore/Components/Keychain.swift index 8f933e693..dab0a6e90 100644 --- a/AltStoreCore/Components/Keychain.swift +++ b/AltStoreCore/Components/Keychain.swift @@ -41,12 +41,48 @@ public struct KeychainItem } } +/// The set of per-account secrets required to authenticate an Apple account and re-sign its apps. +/// +/// Anisette machine state (`identifier` / `adiPb`) is intentionally NOT part of this — it is +/// device-scoped and shared across all accounts, so it stays in the global `Keychain` slots. +public struct AccountCredentials +{ + public var emailAddress: String? + public var password: String? + public var adsid: String? // ALTAppleAPISession.dsid + public var xcodeToken: String? // ALTAppleAPISession.authToken + public var signingCertificate: Data? // PKCS#12 + public var signingCertificatePassword: String? + + public init(emailAddress: String? = nil, + password: String? = nil, + adsid: String? = nil, + xcodeToken: String? = nil, + signingCertificate: Data? = nil, + signingCertificatePassword: String? = nil) + { + self.emailAddress = emailAddress + self.password = password + self.adsid = adsid + self.xcodeToken = xcodeToken + self.signingCertificate = signingCertificate + self.signingCertificatePassword = signingCertificatePassword + } + + /// Whether these credentials are sufficient to (attempt to) authenticate the account + /// without prompting the user for a password again. + public var canAuthenticate: Bool { + let hasToken = (self.adsid?.isEmpty == false) && (self.xcodeToken?.isEmpty == false) + let hasPassword = (self.emailAddress?.isEmpty == false) && (self.password?.isEmpty == false) + return hasToken || hasPassword + } +} + public class Keychain { public static let shared = Keychain() - - public let keychain = KeychainAccess.Keychain(service: Bundle.Info.appbundleIdentifier).accessibility(.afterFirstUnlock).synchronizable(true) - + + public let keychain = KeychainAccess.Keychain(service: Bundle.Info.appbundleIdentifier).accessibility(.afterFirstUnlock).synchronizable(true) @KeychainItem(key: "appleIDEmailAddress") public var appleIDEmailAddress: String? @@ -83,10 +119,24 @@ public class Keychain // for some reason authenticated cert/session/team is completely not cached, which result in logging in for every request // we save it here so when user logs out we can clear cached account/session/team + // + // NOTE: These three properties mirror the *default* (active) account's cached state and are + // kept for backwards compatibility. Multi-account callers should use the per-account cache + // accessors below (`cachedCertificate(forAccount:)` etc.), which isolate each account's + // in-memory session/certificate/team. public var certificate: ALTCertificate? = nil public var session: ALTAppleAPISession? = nil public var team: ALTTeam? = nil - + + // MARK: Per-account in-memory cache + // Isolated, non-persisted cache of the authenticated session/certificate/team for each + // account, keyed by `Account.identifier`. Guarded by `accountCacheLock` because multiple + // accounts can be authenticated/refreshed concurrently. + private let accountCacheLock = NSLock() + private var accountSessions: [String: ALTAppleAPISession] = [:] + private var accountCertificates: [String: ALTCertificate] = [:] + private var accountTeams: [String: ALTTeam] = [:] + private init() { self.migrateLegacyKeychainItems() @@ -144,3 +194,119 @@ public class Keychain self.team = nil } } + +// MARK: - Per-account credential & session storage +// +// Multi-account support stores each account's secrets under a namespaced key +// ("account..") so that any number of Apple accounts can remain +// authenticated simultaneously with fully isolated sessions, certificates and tokens. +// The same underlying (synchronizable, after-first-unlock) keychain is reused, preserving +// SideStore's existing security posture. +public extension Keychain +{ + private func accountKey(_ accountID: String, _ field: String) -> String + { + return "account.\(accountID).\(field)" + } + + private func string(_ accountID: String, _ field: String) -> String? + { + return try? self.keychain.getString(self.accountKey(accountID, field)) + } + + private func data(_ accountID: String, _ field: String) -> Data? + { + return try? self.keychain.getData(self.accountKey(accountID, field)) + } + + private func set(_ value: String?, _ accountID: String, _ field: String) + { + let key = self.accountKey(accountID, field) + if let value = value { try? self.keychain.set(value, key: key) } + else { try? self.keychain.remove(key) } + } + + private func set(_ value: Data?, _ accountID: String, _ field: String) + { + let key = self.accountKey(accountID, field) + if let value = value { try? self.keychain.set(value, key: key) } + else { try? self.keychain.remove(key) } + } + + /// The stored credentials for the given account (empty fields if none stored). + func credentials(forAccount accountID: String) -> AccountCredentials + { + return AccountCredentials( + emailAddress: self.string(accountID, "appleIDEmailAddress"), + password: self.string(accountID, "appleIDPassword"), + adsid: self.string(accountID, "appleIDAdsid"), + xcodeToken: self.string(accountID, "appleIDXcodeToken"), + signingCertificate: self.data(accountID, "signingCertificate"), + signingCertificatePassword: self.string(accountID, "signingCertificatePassword") + ) + } + + /// Persist credentials for the given account. Only non-nil fields are written; pass an + /// explicit nil field to clear just that value. + func setCredentials(_ credentials: AccountCredentials, forAccount accountID: String) + { + self.set(credentials.emailAddress, accountID, "appleIDEmailAddress") + self.set(credentials.password, accountID, "appleIDPassword") + self.set(credentials.adsid, accountID, "appleIDAdsid") + self.set(credentials.xcodeToken, accountID, "appleIDXcodeToken") + self.set(credentials.signingCertificate, accountID, "signingCertificate") + self.set(credentials.signingCertificatePassword, accountID, "signingCertificatePassword") + } + + /// Remove all persisted credentials and drop the in-memory session cache for an account. + func removeCredentials(forAccount accountID: String) + { + for field in ["appleIDEmailAddress", "appleIDPassword", "appleIDAdsid", "appleIDXcodeToken", "signingCertificate", "signingCertificatePassword"] + { + try? self.keychain.remove(self.accountKey(accountID, field)) + } + self.clearCachedSession(forAccount: accountID) + } + + /// Whether the account has enough stored credentials to attempt a silent re-authentication. + func hasCredentials(forAccount accountID: String) -> Bool + { + return self.credentials(forAccount: accountID).canAuthenticate + } + + // MARK: In-memory per-account session cache + + func cachedSession(forAccount accountID: String) -> ALTAppleAPISession? + { + self.accountCacheLock.lock(); defer { self.accountCacheLock.unlock() } + return self.accountSessions[accountID] + } + + func cachedCertificate(forAccount accountID: String) -> ALTCertificate? + { + self.accountCacheLock.lock(); defer { self.accountCacheLock.unlock() } + return self.accountCertificates[accountID] + } + + func cachedTeam(forAccount accountID: String) -> ALTTeam? + { + self.accountCacheLock.lock(); defer { self.accountCacheLock.unlock() } + return self.accountTeams[accountID] + } + + func cache(session: ALTAppleAPISession?, certificate: ALTCertificate?, team: ALTTeam?, forAccount accountID: String) + { + self.accountCacheLock.lock(); defer { self.accountCacheLock.unlock() } + self.accountSessions[accountID] = session + self.accountCertificates[accountID] = certificate + self.accountTeams[accountID] = team + } + + func clearCachedSession(forAccount accountID: String) + { + self.accountCacheLock.lock(); defer { self.accountCacheLock.unlock() } + self.accountSessions[accountID] = nil + self.accountCertificates[accountID] = nil + self.accountTeams[accountID] = nil + } +} diff --git a/AltStoreCore/Managers/AccountManager.swift b/AltStoreCore/Managers/AccountManager.swift new file mode 100644 index 000000000..2289f291b --- /dev/null +++ b/AltStoreCore/Managers/AccountManager.swift @@ -0,0 +1,268 @@ +// +// AccountManager.swift +// AltStoreCore +// +// Manages multiple Apple Developer accounts and the mapping between installed +// apps and the account that signs them. +// + +import Foundation +import CoreData + +import AltSign + +/// Single entry point for everything related to Apple accounts. +/// +/// SideStore historically assumed exactly one Apple account (the "active" account/team plus one +/// global set of credentials in the `Keychain`). `AccountManager` generalises this to any number +/// of accounts: it owns the account ↔ app mapping, resolves which account should sign a given app, +/// and exposes per-account credential state. It deliberately holds **no mutable global state** — +/// every query reads from Core Data / the `Keychain`, so there is a single source of truth and no +/// cached "current account" to keep in sync. +/// +/// Interactive operations that require the app layer (adding an account via the login UI, or +/// refreshing an account's apps) are implemented in an `AccountManager` extension inside the +/// AltStore target, where `AppManager` and `AuthenticationOperation` are available. +public class AccountManager +{ + public static let shared = AccountManager() + + private init() {} +} + +// MARK: - Queries +public extension AccountManager +{ + /// All Apple accounts known to SideStore. + func listAccounts(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> [Account] + { + return Account.all(sortedBy: [NSSortDescriptor(keyPath: \Account.appleID, ascending: true)], in: context) + } + + /// The account with the given identifier (`Account.identifier`), if any. + func account(_ identifier: String, in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> Account? + { + let predicate = NSPredicate(format: "%K == %@", #keyPath(Account.identifier), identifier) + return Account.first(satisfying: predicate, in: context) + } + + /// The default account used when installing a new app (the legacy "active" account). + func defaultAccount(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> Account? + { + return DatabaseManager.shared.activeAccount(in: context) + } + + /// Accounts that currently have usable stored credentials (i.e. can be authenticated + /// without prompting the user again). These are the accounts able to sign/refresh apps. + func activeAccounts(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> [Account] + { + return self.listAccounts(in: context).filter { self.hasValidCredentials(for: $0) } + } + + /// Whether the account has enough stored secrets to attempt a silent authentication. + func hasValidCredentials(for account: Account) -> Bool + { + return Keychain.shared.hasCredentials(forAccount: account.identifier) + } +} + +// MARK: - App ↔ account mapping +public extension AccountManager +{ + /// The account responsible for signing `app`, resolved via its permanent `signingAccountID` + /// (falling back to the account of its `team` for apps installed before multi-account support). + /// + /// Must be called on `app`'s managed object context's queue. + func accountForApp(_ app: InstalledApp) -> Account? + { + guard let context = app.managedObjectContext else { return nil } + guard let accountID = app.resolvedSigningAccountID else { return nil } + return self.account(accountID, in: context) + } + + /// All installed apps assigned to the given account. + /// + /// Matches on the stored `signingAccountID` as well as the legacy `team.account.identifier` + /// path so apps are correctly grouped both before and after the backfill migration runs. + func appsForAccount(_ accountID: String, in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> [InstalledApp] + { + let predicate = NSPredicate(format: "%K == %@ OR (%K == nil AND %K == %@)", + #keyPath(InstalledApp.signingAccountID), accountID, + #keyPath(InstalledApp.signingAccountID), + #keyPath(InstalledApp.team.account.identifier), accountID) + return InstalledApp.all(satisfying: predicate, in: context) + } + + /// Permanently record which account signs an app, keeping the `team` relationship consistent. + /// + /// This only updates the persisted mapping; callers are expected to trigger a re-sign + /// afterwards so the app is actually signed by the newly-assigned account. + /// Must be called on `context`'s queue; does not save. + @discardableResult + func assignAccount(_ accountID: String, toAppWithBundleIdentifier bundleIdentifier: String, in context: NSManagedObjectContext) -> Bool + { + let appPredicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), bundleIdentifier) + guard let installedApp = InstalledApp.first(satisfying: appPredicate, in: context) else { return false } + guard let account = self.account(accountID, in: context) else { return false } + + installedApp.signingAccountID = accountID + + // Bind the app to one of the account's teams so free/paid limits and profile lookups + // continue to resolve through the existing team relationship. Prefer the active team. + let team = account.teams.first(where: { $0.isActiveTeam }) ?? account.teams.first + if let team = team + { + installedApp.team = team + } + + // Re-signing is required to actually switch the signer. + installedApp.needsResign = true + + return true + } + + /// Designate `accountID` as the default account for new installs, keeping exactly one team of + /// that account active, and mirror its credentials + cached session into the legacy global + /// keychain slots so single-account UI/paths keep working. Pass `nil` to clear the default + /// (e.g. after the last account is removed). Must be called on `context`'s queue; does not save. + func setDefaultAccount(_ accountID: String?, in context: NSManagedObjectContext) + { + for account in self.listAccounts(in: context) + { + let isDefault = (account.identifier == accountID) + account.isActiveAccount = isDefault + + if isDefault + { + let preferredTeam = account.teams.first(where: { $0.isActiveTeam }) ?? account.teams.first + for team in account.teams { team.isActiveTeam = (team === preferredTeam) } + } + else + { + for team in account.teams { team.isActiveTeam = false } + } + } + + // Mirror the default account's secrets into the global keychain slots so legacy + // single-account consumers (certificate management, "signed in?" checks, SideStore + // self-sign) continue to work unchanged. + let keychain = Keychain.shared + if let accountID = accountID + { + let credentials = keychain.credentials(forAccount: accountID) + keychain.appleIDEmailAddress = credentials.emailAddress + keychain.appleIDPassword = credentials.password + keychain.appleIDAdsid = credentials.adsid + keychain.appleIDXcodeToken = credentials.xcodeToken + keychain.signingCertificate = credentials.signingCertificate + keychain.signingCertificatePassword = credentials.signingCertificatePassword + keychain.session = keychain.cachedSession(forAccount: accountID) + keychain.certificate = keychain.cachedCertificate(forAccount: accountID) + keychain.team = keychain.cachedTeam(forAccount: accountID) + } + else + { + keychain.reset() + } + } + + /// Remove an account: clear its stored credentials, delete the `Account` (cascading to its + /// teams), and — if it was the default — promote another account as the new default. Installed + /// apps keep their `signingAccountID` so re-adding the same Apple ID automatically re-links + /// them; until then those apps fail to refresh in isolation. Must be called on `context`'s + /// queue; saves the context. + func deleteAccount(_ accountID: String, in context: NSManagedObjectContext) throws + { + Keychain.shared.removeCredentials(forAccount: accountID) + + guard let account = self.account(accountID, in: context) else { return } + let wasDefault = account.isActiveAccount + + context.delete(account) + try context.save() + + if wasDefault + { + let replacement = self.activeAccounts(in: context).first ?? self.listAccounts(in: context).first + self.setDefaultAccount(replacement?.identifier, in: context) + try context.save() + } + } +} + +// MARK: - Migration +public extension AccountManager +{ + /// Run the one-time (idempotent) multi-account migrations on a private background context: + /// re-home legacy global credentials into per-account storage and backfill each app's + /// `signingAccountID`. Safe to call on every launch. + func performStartupMigrations() + { + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + context.performAndWait { + self.migrateLegacyCredentialsIfNeeded(in: context) + let updated = self.backfillSigningAccountIDs(in: context) + + if updated > 0 || context.hasChanges + { + do { try context.save() } + catch { debugLog("[AccountManager] Failed to save startup migrations: \(error)") } + } + } + } + + /// Migrate a pre-multi-account installation so the existing single account becomes a + /// first-class account with its own per-account credentials. Copies the legacy global + /// credentials into the default account's per-account slots. Idempotent and safe to call + /// on every launch. + func migrateLegacyCredentialsIfNeeded(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) + { + guard let account = self.defaultAccount(in: context) else { return } + + // Already migrated — nothing to do. + guard !Keychain.shared.hasCredentials(forAccount: account.identifier) else { return } + + let keychain = Keychain.shared + let legacy = AccountCredentials( + emailAddress: keychain.appleIDEmailAddress ?? account.appleID, + password: keychain.appleIDPassword, + adsid: keychain.appleIDAdsid, + xcodeToken: keychain.appleIDXcodeToken, + signingCertificate: keychain.signingCertificate, + signingCertificatePassword: keychain.signingCertificatePassword + ) + + guard legacy.canAuthenticate || legacy.signingCertificate != nil else { return } + + keychain.setCredentials(legacy, forAccount: account.identifier) + debugLog("[AccountManager] Migrated legacy credentials to per-account storage for \(account.appleID).") + } + + /// Backfill `signingAccountID` for apps installed before the field existed, using the account + /// of each app's `team` (falling back to the default account). Idempotent; does not save. + /// Returns the number of apps updated. + @discardableResult + func backfillSigningAccountIDs(in context: NSManagedObjectContext) -> Int + { + let predicate = NSPredicate(format: "%K == nil", #keyPath(InstalledApp.signingAccountID)) + let apps = InstalledApp.all(satisfying: predicate, in: context) + guard !apps.isEmpty else { return 0 } + + let fallbackAccountID = self.defaultAccount(in: context)?.identifier + + var updated = 0 + for app in apps + { + guard let accountID = app.team?.account?.identifier ?? fallbackAccountID else { continue } + app.signingAccountID = accountID + updated += 1 + } + + if updated > 0 + { + debugLog("[AccountManager] Backfilled signingAccountID for \(updated) app(s).") + } + + return updated + } +} diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/.xccurrentversion b/AltStoreCore/Model/AltStore.xcdatamodeld/.xccurrentversion index 676a7f4fa..66e586cdd 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/.xccurrentversion +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - AltStore 17_1.xcdatamodel + AltStore 18.xcdatamodel diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 18.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 18.xcdatamodel/contents new file mode 100644 index 000000000..472cb89e6 --- /dev/null +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 18.xcdatamodel/contents @@ -0,0 +1,317 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index c26031ab4..fc9f4c82f 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -64,7 +64,16 @@ public class InstalledApp: BaseEntity, InstalledAppProtocol @NSManaged public var certificateSerialNumber: String? @NSManaged public var storeBuildVersion: String? - + + /// Identifier (matching `Account.identifier`) of the Apple account that signed this app. + /// + /// This permanently records which Apple account is responsible for (re)signing the app, + /// so subsequent refreshes always authenticate with — and re-sign using — the same account + /// that originally installed it. Optional for backwards compatibility with apps installed + /// before multi-account support existed; use `resolvedSigningAccountID` to read it, which + /// falls back to the account of the app's `team` for those legacy rows. + @NSManaged public var signingAccountID: String? + /* Transient */ @NSManaged public var isRefreshing: Bool @@ -78,6 +87,15 @@ public class InstalledApp: BaseEntity, InstalledAppProtocol public var isSideloaded: Bool { return self.storeApp == nil } + + /// The identifier of the Apple account that signs this app. + /// + /// Prefers the explicitly-stored `signingAccountID`, falling back to the account of the + /// app's `team` relationship for apps installed before `signingAccountID` was introduced. + /// This is the canonical way to determine which account should refresh/re-sign the app. + public var resolvedSigningAccountID: String? { + return self.signingAccountID ?? self.team?.account?.identifier + } @objc public var hasUpdate: Bool { // Basic validation diff --git a/AltWidget/Resources/ReleaseEntitlements.plist b/AltWidget/Resources/ReleaseEntitlements.plist index 7f8daaa29..a1ccdc3b4 100644 --- a/AltWidget/Resources/ReleaseEntitlements.plist +++ b/AltWidget/Resources/ReleaseEntitlements.plist @@ -3,12 +3,12 @@ application-identifier - XYZ0123456.com.SideStore.SideStore.AltWidget + XYZ0123456.com.SideStore.MultiStore.AltWidget com.apple.developer.team-identifier XYZ0123456 com.apple.security.application-groups - group.com.SideStore.SideStore + group.com.SideStore.MultiStore get-task-allow diff --git a/Build.xcconfig b/Build.xcconfig index ae8064a02..f5f5ee49d 100644 --- a/Build.xcconfig +++ b/Build.xcconfig @@ -16,7 +16,11 @@ CODE_SIGN_ENTITLEMENTS = ./AltStore/AltStoreFree.entitlements PRODUCT_NAME = SideStore -BASE_BUNDLE_ID = $(ORG_IDENTIFIER).SideStore +// Multi-account fork: install under a distinct bundle identifier ("MultiStore") so it coexists +// with an existing SideStore install. PRODUCT_NAME stays "SideStore" because the Makefile +// packaging targets reference SideStore.app/SideStore; the on-device display name is set to +// "MultiStore" via CFBundleDisplayName in AltStore/Info.plist. +BASE_BUNDLE_ID = $(ORG_IDENTIFIER).MultiStore MAIN_BUNDLE_IDENTIFIER[config=Debug] = $(BASE_BUNDLE_ID).$(DEVELOPMENT_TEAM) MAIN_BUNDLE_IDENTIFIER[config=Release] = $(BASE_BUNDLE_ID) APP_GROUP_IDENTIFIER = $(MAIN_BUNDLE_IDENTIFIER) diff --git a/Shared/Extensions/Bundle+AltStore.swift b/Shared/Extensions/Bundle+AltStore.swift index fccfa7d80..308012ac5 100644 --- a/Shared/Extensions/Bundle+AltStore.swift +++ b/Shared/Extensions/Bundle+AltStore.swift @@ -19,7 +19,10 @@ public extension Bundle public static let altBundleID = "ALTBundleIdentifier" public static let storeAppBundleIdentifier = "com.SideStore.SideStore" // public static var appbundleIdentifier = Bundle.main.bundleIdentifier - public static let appbundleIdentifier = "com.SideStore.SideStore" // for now lets use what we had so far + // Multi-account fork identity: must match the app's actual bundle id (BASE_BUNDLE_ID in + // Build.xcconfig) so the keychain namespace, app group and self-refresh detection are + // isolated from — and don't collide with — a coexisting SideStore install. + public static let appbundleIdentifier = "com.SideStore.MultiStore" public static let devicePairingString = "ALTPairingFile" public static let urlTypes = "CFBundleURLTypes" diff --git a/docs/multi-account/ARCHITECTURE.md b/docs/multi-account/ARCHITECTURE.md new file mode 100644 index 000000000..9fa820190 --- /dev/null +++ b/docs/multi-account/ARCHITECTURE.md @@ -0,0 +1,172 @@ +# Multi-Account Signing — Architecture Analysis (Phase 1) + +This document explains how SideStore currently authenticates, signs, installs and +refreshes apps, and enumerates every place that assumes a **single** Apple +Developer account. It is the basis for the incremental refactor described in +[`PLAN.md`](PLAN.md). + +> TL;DR — The **signing/provisioning pipeline is already parameterised** by an +> `AuthenticatedOperationContext` that carries a `session` + `team` + +> `certificate`. The single-account assumption is **not** in the pipeline; it is +> concentrated in five places: the global `Keychain`, `AuthenticationOperation`, +> `AppManager.refresh`, `InstallAppOperation`, and the account UI. Generalising +> those five is the whole job. + +--- + +## 1. Authentication + +### Apple ID login +- **`AltStore/Operations/AuthenticationOperation.swift`** is the whole login flow. + It is a `ResultOperation<(ALTTeam, ALTCertificate?, ALTAppleAPISession)>`. +- Login path (`signIn()` → `authenticate(appleID:password:)` / + `authenticateWithToken(adsid:xcodeToken:)`): fetch anisette data → call + `ALTAppleAPI.shared.authenticate(...)` (2FA handled via alert) → obtain + `ALTAccount` + `ALTAppleAPISession`. +- Team selection: `fetchTeam(for:session:)` → `ALTAppleAPI.fetchTeams` → + if a DB "active team" exists use it, else `selectTeam` (UI if >1). +- Certificate: `fetchCertificate(for:session:)` reuses the cached + `signingCertificate` if it matches a live cert, else requests/replaces one. +- Device registration + `cacheAppIDs`. + +### Session persistence / token storage +- **`AltStoreCore/Components/Keychain.swift`** — a **singleton** (`Keychain.shared`) + that stores exactly one set of credentials: + - `appleIDEmailAddress`, `appleIDPassword`, `appleIDAdsid` (dsid), + `appleIDXcodeToken` (authToken) — **per-account** values, stored globally. + - `signingCertificate` (PKCS#12), `signingCertificatePassword` — **per-account**. + - `identifier`, `adiPb` — **per-device anisette** state (correctly shared + across accounts; must NOT be duplicated per account). + - In-memory cache: `certificate`, `session`, `team` — one of each. +- The cached `session/team/certificate` short-circuit re-login in + `AuthenticationOperation.main()`. + +### Team IDs / certificate management / provisioning +- Team, certificate, device, App ID and provisioning-profile calls all take an + explicit `team` + `session`, so they are **not** globally bound — they are + driven by whatever the operation's context holds. + +### Single-account assumptions (auth) +| Location | Assumption | +|---|---| +| `Keychain.swift` (all credential keys + `certificate`/`session`/`team`) | One credential set, one cached session/cert/team for the whole app. | +| `AuthenticationOperation.main` L96-134 | Reuses the single cached `Keychain.shared.session/team/certificate`. | +| `AuthenticationOperation.postAuthenticationCleanup` L237-256 | Sets `isActiveAccount = true` on this account and `false` on **all others**, and same for `isActiveTeam` — i.e. exactly one active account/team. | +| `AuthenticationOperation.signIn` L358-382 | Reads the single global `appleIDEmailAddress`/`appleIDAdsid`. | +| `AuthenticationOperation.fetchTeam` L505-509 | Uses `DatabaseManager.activeTeam` as "the" team. | + +--- + +## 2. Signing pipeline + +Entry points live in **`AltStore/Managing Apps/AppManager.swift`**; the heavy +lifting is in `AltStore/Operations/*`. Every operation is threaded an +`AppOperationContext` whose `authenticatedContext` is an +`AuthenticatedOperationContext` (`session`/`team`/`certificate`). + +- **IPA preparation + resign** — `ResignAppOperation.swift`. Uses + `self.context.team` + `self.context.certificate` and + `ALTSigner(team:certificate:)`. **Already account-agnostic.** The only global + read is the `app.isAltStoreApp` self-refresh branch (L142-144) which embeds + `Keychain.shared.signingCertificate` into the SideStore bundle. +- **Provisioning profiles** — `FetchProvisioningProfilesOperation.swift` + (`self.context.team`/`session`). Account-agnostic. +- **App IDs** — `FetchAppIDsOperation.swift` (`self.context.team`/`session`). + Account-agnostic. +- **Entitlements** — handled inside `ResignAppOperation.prepare(...)` from the + provisioning profile. Account-agnostic. +- **Install** — `InstallAppOperation.swift`. Uses `self.context.certificate` and + provisioning profiles from context. **But** L153 binds the new + `InstalledApp.team = DatabaseManager.shared.activeTeam` — the single point where + the app→account link is (incorrectly, for multi-account) set to the active team + instead of the team that actually signed it. + +### Single-account assumptions (signing) +| Location | Assumption | +|---|---| +| `InstallAppOperation.findOrCreateInstalledApp` L153 | New apps are always bound to the **active** team. | +| `ResignAppOperation.prepareAppBundle` L142-144 | SideStore self-refresh embeds the single global certificate. | +| `AppManager._refresh` L1600 | Certificate-match check falls back to the single global `Keychain.shared.signingCertificate`. | + +--- + +## 3. Installed applications + +- **Model** — `AltStoreCore/Model/InstalledApp.swift` + Core Data entity + `InstalledApp` (current model version **`AltStore 17_1`**). + - Relationship chain already present: `InstalledApp.team → Team.account → Account`. + - So an app already *can* name its account via `team?.account`. There is **no + explicit, permanent** `signingAccountID` field yet. +- **Persistence** — `AltStoreCore/Model/DatabaseManager/DatabaseManager.swift` + over `RSTPersistentContainer` (`AltStoreCore/Roxas/RSTPersistentContainer.swift`). + - Migration is **progressive** and falls back to + `NSMappingModel.inferredMappingModel` (lightweight). Adding an *optional* + attribute in a new model version therefore migrates automatically with no + explicit mapping model. +- **Refresh queue** — `AppManager` `operationQueue` (concurrent) + + `serialOperationQueue` (install/refresh/backup serialised; SideStore-self last). +- **Expiration tracking** — `InstalledApp.expirationDate` (from the provisioning + profile). `fetchAppsForRefreshingAll` / `fetchAppsForBackgroundRefresh` select + apps to refresh, sorted by expiration. + +### Single-account assumptions (installed apps / persistence) +| Location | Assumption | +|---|---| +| `Account.isActiveAccount`, `Team.isActiveTeam` (model + `DatabaseManager.activeAccount/activeTeam`) | Exactly one "current" account/team. | +| `InstalledApp` | No permanent per-app account field; the account is only implied by the mutable `team` relationship. | +| `Migrations/Policies/InstalledAppPolicy.swift` L24 | Migration policy fetches the single `isActiveTeam` team. | + +--- + +## 4. Refresh workflow + +- **Foreground** — `MyAppsViewController` / app detail → `AppManager.refresh(_:)`. +- **Background** — `BackgroundRefreshAppsOperation.swift` collects + `fetchAppsForBackgroundRefresh` and calls **`AppManager.shared.refresh(apps, …)`**. +- **`AppManager.refresh(_:presentingViewController:group:)`** (L784) creates **one** + `RefreshGroup` (one `AuthenticatedOperationContext`) for **all** apps. +- **`AppManager.perform(_:group:)`** (L1131): if `group.context.session == nil`, + authenticate **once** (L1148) using the group's context (→ global keychain), then + run every app's operations against that **single shared** session/team/cert. + +### Single-account assumptions (refresh) +| Location | Assumption | +|---|---| +| `AppManager.refresh` L784-798 | All apps refreshed in a single group / single account. | +| `AppManager.perform` L1146-1159 | Exactly one authentication per refresh batch. | +| `BackgroundRefreshAppsOperation` | All background apps go through one `refresh(...)` group. | + +**Consequence for failure isolation:** because there is one context and one +`context.error`, an auth/cert/provisioning failure for the single account fails +the *entire* batch. There is currently no isolation because there is only ever +one account. + +--- + +## 5. UI touch-points that read the single account + +| Location | Use | +|---|---| +| `AltStore/Settings/SettingsViewController.swift` (`activeTeam`, `signIn`, `signOut`, `.signIn`/`.account` sections) | Shows the one signed-in account; sign-in/out. | +| `AltStore/App IDs/AppIDsViewController.swift` L91/223/322 | Uses `activeTeam`. | +| `AltStore/My Apps/MyAppsViewController.swift` L1805/2187 | Footer / sizing keyed on `activeTeam != nil`. | +| `AltStore/LaunchViewController.swift` L185-192 & `SettingsViewController` L286-295 | "Import account" writes the global keychain credentials. | +| `AltStore/Settings/Certificates/CertificatesViewModel.swift` | Manages the single global certificate. | + +--- + +## 6. Summary of the five things to generalise + +1. **`Keychain`** — one credential set + one cached session/cert/team → keyed by + account identifier (device-level anisette `identifier`/`adiPb` stay global). +2. **`AuthenticationOperation`** — authenticate a *specified* account; stop forcing + a single `isActiveAccount`/`isActiveTeam`. +3. **`AppManager.refresh`** — partition apps by their assigned account and refresh + each account in its own group/context (→ natural failure isolation). +4. **`InstallAppOperation`** — bind a new app to the team/account that *signed* it, + and persist a permanent `signingAccountID`. +5. **UI** — manage N accounts (add/remove/list/status) and show/change an app's + signing account. + +Everything else (resign, provisioning, App IDs, entitlements, install transport) +is already context-driven and needs no change. diff --git a/docs/multi-account/PLAN.md b/docs/multi-account/PLAN.md new file mode 100644 index 000000000..ecef91f35 --- /dev/null +++ b/docs/multi-account/PLAN.md @@ -0,0 +1,148 @@ +# Multi-Account Signing — Implementation Plan (Phase 2) + +Derived from [`ARCHITECTURE.md`](ARCHITECTURE.md). The guiding principle is the +**smallest clean refactor** that generalises SideStore from one Apple account to +many, reusing the existing `AuthenticatedOperationContext`-driven pipeline and +matching the existing coding style. Existing single-account behaviour is a +special case (N = 1) of the new design. + +## Design summary + +- The signing pipeline already carries a per-operation `session`/`team`/`certificate`. + We make **who fills that context** account-aware instead of globally fixed. +- Credentials become **per-account** in the Keychain, keyed by `Account.identifier`. + Device-level anisette state (`identifier`, `adiPb`) stays global. +- Each `InstalledApp` gains a permanent **`signingAccountID`**. +- Refresh **partitions apps by account**, runs one authenticated group per account, + and aggregates results — giving failure isolation for free. +- A new **`AccountManager`** facade is the single entry point the rest of the app + uses; it holds **no mutable global state** (it reads/writes Core Data + Keychain). +- `isActiveAccount`/`isActiveTeam` are **retained** but re-interpreted as the + **default account** (used for new installs / legacy UI), not "the only account". + +## Architecture changes + +| Area | Change | +|---|---| +| Data model | New model version **`AltStore 18`**: add optional `InstalledApp.signingAccountID: String`. Lightweight/inferred migration. | +| Keychain | Add per-account credential + session/cert/team storage keyed by account id; keep global keys for anisette + migration. | +| Context | `AuthenticatedOperationContext` gains `accountID: String?` (target account for this context). | +| Auth | `AuthenticationOperation` authenticates the context's `accountID` from per-account creds; stops nuking other accounts' active flags. | +| Install | `InstallAppOperation` binds the app to the **signing** team/account + sets `signingAccountID`. | +| Refresh | `AppManager.refresh` partitions by account into child groups + aggregate. | +| New type | `AccountManager` (AltStoreCore) with the required API. | +| UI | Accounts list + add/remove/status in Settings; signing-account row + change in app detail. | + +## Files to modify / add + +**Add** +- `AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 18.xcdatamodel/contents` (+ set `.xccurrentversion`) +- `AltStoreCore/Managers/AccountManager.swift` +- `AltStore/Settings/Accounts/AccountsViewController.swift` (+ minimal cell/rows) +- `docs/multi-account/*` (this analysis/plan) + +**Modify** +- `AltStoreCore/Model/InstalledApp.swift` — `signingAccountID` + `signingAccount` helpers +- `AltStoreCore/Components/Keychain.swift` — per-account API +- `AltStoreCore/Model/DatabaseManager/DatabaseManager.swift` — startup backfill/migration hook; keep `activeAccount/activeTeam` as "default" +- `AltStore/Operations/Common/OperationContexts.swift` — `accountID` +- `AltStore/Operations/AuthenticationOperation.swift` — per-account auth + credential store +- `AltStore/Operations/InstallAppOperation.swift` — bind signing team/account +- `AltStore/Managing Apps/AppManager.swift` — account-partitioned refresh + aggregate group; account-scoped resign helper +- `AltStore/Settings/SettingsViewController.swift` — entry to Accounts screen (keep existing single-account rows working) +- App detail (`AltStore/App Detail/…`) — signing-account row + change action + +## Data-model change & migration strategy + +1. Duplicate `AltStore 17_1.xcdatamodel` → `AltStore 18.xcdatamodel`, add + `` to + `InstalledApp`, bump `userDefinedModelVersionIdentifier` to `v18`, and point + `.xccurrentversion` at it. Xcode 16 file-system-synchronised groups pick the new + version up automatically; `RSTPersistentContainer` migrates via the inferred + (lightweight) mapping model. +2. **Automatic data migration (Phase 8)** at startup (`DatabaseManager.prepareDatabase` + or a dedicated one-shot): for every `InstalledApp` with `signingAccountID == nil`, + backfill it from `team?.account?.identifier` (or the active account). Idempotent. +3. **Automatic credential migration**: on first launch after update, if legacy global + credentials exist and an active account exists, copy them into that account's + per-account Keychain slots. Preserves the existing user's login as "Account #1". + +No user loses apps or signing info: existing rows keep their `team` relationship and +gain a `signingAccountID`; existing credentials are re-homed, not recreated. + +## AccountManager API (Phase 3) + +`AccountManager.shared` — stateless facade (no mutable stored properties): + +- `listAccounts(in:) -> [Account]` +- `account(_ id: String, in:) -> Account?` +- `activeAccounts(in:) -> [Account]` — accounts that currently have usable credentials +- `defaultAccount(in:) -> Account?` — the `isActiveAccount` account (new-install default) +- `addAccount(presentingViewController:) async -> Result` — interactive login for a new Apple ID, stored per-account +- `removeAccount(_:) async -> Result` — clear that account's credentials + delete `Account` +- `updateAccount(_:) ` — refresh stored profile info +- `accountForApp(_ app: InstalledApp, in:) -> Account?` — resolve via `signingAccountID` (fallback `team?.account`) +- `assignAccount(_ id:, toApp:) ` — set `signingAccountID` (+ team) permanently +- `refreshAccount(_ id:, presentingViewController:) -> RefreshGroup` — refresh only that account's apps + +## Multiple sessions (Phase 4) + +- Per-account Keychain slots: `".appleIDPassword"` etc., plus a per-account + in-memory `[accountID: (session, cert, team)]` cache. +- `AuthenticatedOperationContext.accountID` selects which credentials + `AuthenticationOperation` loads/stores. `nil` = default account / interactive add. +- Sessions, certificates, teams and App IDs are already isolated by context; storing + them per-account completes isolation. + +## App→account mapping (Phase 5) + +- `InstalledApp.signingAccountID` (permanent). Set on install from the signing team's + account. `InstallAppOperation` uses `context.team` (the team that signed) rather than + `activeTeam`. + +## Per-account refresh + failure isolation (Phase 6/7) + +- `AppManager.refresh(apps)`: + 1. Resolve each app's account (`AccountManager.accountForApp`). + 2. Group apps by account id. + 3. `N ≤ 1` → existing single-group path (unchanged behaviour). + 4. `N > 1` → one child `RefreshGroup` per account (`child.context.accountID = id`), + run concurrently via `perform`; an **aggregate** group merges child results, + progress and `beginInstallationHandler`, and fires its `completionHandler` when + all children finish. +- Because each child has its own context/session/`error`, one account's failure only + fails that child's apps — other accounts keep refreshing. Apps whose account is + missing/credential-less fail only themselves with a clear error. + +## UI (Phase 9 — minimal) + +- **Settings → Accounts**: list accounts with status (has valid credentials?), + Add Account, Remove Account. Existing single-account rows keep working (show default). +- **App detail**: a row showing the current signing account + an action to change it + (assign a different account, then resign). + +## Testing strategy (Phase 10) + +Local `xcodebuild` is impossible on this Windows host, so **GitHub Actions +(`.github/workflows/multi-account-ci.yml`, macOS)** is the authoritative build/test: +- `build` job: `make build` (archive, no signing) — full compile of every changed file. +- `unit-tests` job: `make build-tests` + the DataStructures unit-test plan. +- Push after each logical milestone; fix compile errors before proceeding. + +Behavioural verification (reasoned + code-level, mirroring the DoD scenarios): +single-account install/refresh/migration still work (N=1 path unchanged); install with +account A vs B; refresh A vs B apps; correct account auto-selected via `signingAccountID`; +failure isolation (bad creds / expired / removed account / revoked profile affect only +that account's apps). + +## Milestones (commit boundaries) + +1. CI workflow (done) + docs. +2. Data model v18 + `InstalledApp.signingAccountID`. +3. Keychain per-account storage. +4. `AccountManager`. +5. Context `accountID` + `AuthenticationOperation` per-account auth + credential migration. +6. `InstallAppOperation` signing-account binding + data backfill migration. +7. `AppManager` account-partitioned refresh + aggregate + isolation. +8. Minimal UI (accounts management + app-detail signing account). +9. Build/test hardening via CI until green.