Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Palace.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
114C14F01FFD0AD65BD9E2A8 /* TPPSettingsAuthBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB91C36D9DC90CDAC98FA568 /* TPPSettingsAuthBridge.swift */; };
119503E71993F914009FB788 /* libxml2.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 119503E61993F914009FB788 /* libxml2.dylib */; };
119503E91993F919009FB788 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 119503E81993F919009FB788 /* libz.dylib */; };
121523B3892A09CCEDACF8CC /* AccountCredentialResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DB71A079F6AD144689D1E63 /* AccountCredentialResolver.swift */; };
121E8B66B8634CD720E7C524 /* CatalogRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA39820CBA75D6BA9B763E2 /* CatalogRepositoryTests.swift */; };
1246CC54E04F756C2913D438 /* LCPPDFDiskExtractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F470EA0E88BDCE56E27B86 /* LCPPDFDiskExtractTests.swift */; };
12695B15A63B66C4B42E9B13 /* TPPReaderFootnoteAccessibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD60FEA388441F72F8C8D85C /* TPPReaderFootnoteAccessibilityTests.swift */; };
Expand Down Expand Up @@ -125,6 +126,7 @@
161DDED234AF46D984276897 /* EmailAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B222C03630C4B2984C38A28 /* EmailAddressTests.swift */; };
165C30CC09A69EA31390E6B5 /* RatingReviewRequester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F892EAF356E802A7E33A657 /* RatingReviewRequester.swift */; };
1698C14BA6720F592D8A0834 /* TestAppContainerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2F955BD354E7BE3DEFB5B74 /* TestAppContainerFactory.swift */; };
16AEE219FDEB9C1FB699C6F9 /* AccountCredentialResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DB71A079F6AD144689D1E63 /* AccountCredentialResolver.swift */; };
16D1246F3608F88A422AE6BA /* SideloadedBookRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F96D2A6AAB3B6C802BDBFCF /* SideloadedBookRegistry.swift */; };
16E9A34D3D9D5833B73139CF /* iPadOnMacRMSDKGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20222DD2B8B50BD8CDF984D /* iPadOnMacRMSDKGuardTests.swift */; };
17071065242A923400E2648F /* TPPSecrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17071060242A923400E2648F /* TPPSecrets.swift */; };
Expand Down Expand Up @@ -1921,6 +1923,7 @@
F9118F50C0F64CE998AC7527 /* OPDS2ParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45081C08344C4032BAB1EE86 /* OPDS2ParsingTests.swift */; };
F91715377B924C738EEAC8F3 /* FirebaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DD7FF880843F7BE282614 /* FirebaseManager.swift */; };
F9497743E78564D80AEC155F /* TPPReaderPageListBusinessLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D848CD4CD0E01B3A2BF67FE /* TPPReaderPageListBusinessLogic.swift */; };
F957246841935C2C67540008 /* AccountCredentialResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3A15BCE8862A26CC2EF7A6 /* AccountCredentialResolverTests.swift */; };
FA346754BE76A48C92CF0754 /* OPDS2FeedParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD86C00D774B74F80D5B713 /* OPDS2FeedParsingTests.swift */; };
FA60C0B01CF7E5F52C5C3A0C /* TPPBookRegistryAccountCaptureContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0A032C501D578FE2D25C40 /* TPPBookRegistryAccountCaptureContractTests.swift */; };
FA8184531683826DDA00EB04 /* BorrowOperationContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958964493F9D85E5FCFAAAE2 /* BorrowOperationContractTests.swift */; };
Expand Down Expand Up @@ -2840,6 +2843,7 @@
8CE9C470237F84820072E964 /* TPPBookDetailsProblemDocumentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPPBookDetailsProblemDocumentViewController.swift; sourceTree = "<group>"; };
8D50821D42294719B5E6F62C /* UIApplication+MainScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+MainScene.swift"; sourceTree = "<group>"; };
8D899B519469E743D48FFACC /* AudiobookPlaytimesLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookPlaytimesLifecycleTests.swift; sourceTree = "<group>"; };
8DB71A079F6AD144689D1E63 /* AccountCredentialResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountCredentialResolver.swift; sourceTree = "<group>"; };
8DD49518A593F93C52009D35 /* KeyboardNavigationHandlerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KeyboardNavigationHandlerTests.swift; path = PalaceTests/Reader/KeyboardNavigationHandlerTests.swift; sourceTree = "<group>"; };
8E0832A22CC1EC07844CD4AA /* SpyAuthDecisionRecorder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpyAuthDecisionRecorder.swift; sourceTree = "<group>"; };
8E22186CA3D046EA9541D1FE /* TPPBookRegistryRecordTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPPBookRegistryRecordTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3051,6 +3055,7 @@
BED352FF5F5E4D307FBF8B42 /* BorrowReducerCoreContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BorrowReducerCoreContractTests.swift; sourceTree = "<group>"; };
BEF1F7C70AC93BF8E2F22148 /* DebugSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DebugSettings.swift; path = Debug/DebugSettings.swift; sourceTree = "<group>"; };
BF102132435465A1B2C3D4E5 /* BookContentResetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookContentResetService.swift; sourceTree = "<group>"; };
BF3A15BCE8862A26CC2EF7A6 /* AccountCredentialResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountCredentialResolverTests.swift; sourceTree = "<group>"; };
BF99D1F39D634C56A70BFE75 /* AudiobookReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookReliabilityTests.swift; sourceTree = "<group>"; };
BFC0D1E2F30415263748495B /* BookReturnServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookReturnServiceTests.swift; sourceTree = "<group>"; };
BFC0D1E2F30415263748496C /* CredentialPromptCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CredentialPromptCoordinatorTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4354,6 +4359,7 @@
9D32B0D46348D5770A7551C4 /* BorrowReauthResettingTests.swift */,
822C34E7CB9D9E06317B7722 /* CatalogCrawlSchedulerTests.swift */,
A64CF3480F3F5C70A6227526 /* AccountsManagerCatalogLoadJoinTests.swift */,
BF3A15BCE8862A26CC2EF7A6 /* AccountCredentialResolverTests.swift */,
);
path = Accounts;
sourceTree = "<group>";
Expand Down Expand Up @@ -4994,6 +5000,7 @@
C49DDACF93DCF459DC310653 /* AccountRegistryStore.swift */,
39D66F77DC2F6D47EEC5AC3A /* AuthDocumentLoader.swift */,
221D9267074DA0CF6A7A3834 /* AccountRegistryLoader.swift */,
8DB71A079F6AD144689D1E63 /* AccountCredentialResolver.swift */,
);
path = Library;
sourceTree = "<group>";
Expand Down Expand Up @@ -8092,6 +8099,7 @@
092C37A2AA45C900DCCD7A8E /* AccountRegistryStoreSeamTests.swift in Sources */,
36BD44B00F9DEB4FB6473B9E /* AuthDocumentLoaderSeamTests.swift in Sources */,
D669BCA946AA705EA8922727 /* AccountRegistryLoaderSeamTests.swift in Sources */,
F957246841935C2C67540008 /* AccountCredentialResolverTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -8664,6 +8672,7 @@
0AE214B06AC5C39759675519 /* AccountRegistryStore.swift in Sources */,
C3FB1B86998C6337127728A9 /* AuthDocumentLoader.swift in Sources */,
9A060754EB6CCAF39D8F7860 /* AccountRegistryLoader.swift in Sources */,
121523B3892A09CCEDACF8CC /* AccountCredentialResolver.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -9239,6 +9248,7 @@
4731E519F95F0E4A6F77BFBF /* AccountRegistryStore.swift in Sources */,
55259176804CBB1F48F10FB3 /* AuthDocumentLoader.swift in Sources */,
B86ABC14203789D64ABC9A16 /* AccountRegistryLoader.swift in Sources */,
16AEE219FDEB9C1FB699C6F9 /* AccountCredentialResolver.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
121 changes: 121 additions & 0 deletions Palace/Accounts/Library/AccountCredentialResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// AccountCredentialResolver.swift
// Palace
//
// god-class decomposition — Wave 3 / 3a-5 (the fifth in-target collaborator split
// out of `AccountsManager`).
//
// Per-account credential resolution: the cache of library-scoped `TPPUserAccount`
// instances (each with immutable keychain keys), the `currentUserAccount` resolution
// with its "ride-out" over the transient `currentAccountId == nil` account-switch
// window, and the fresh-install placeholder. This is the credential-isolation
// boundary — a defect here is a silent cross-account credential leak (F-034) or a
// spurious sign-in modal (F-016) — so the bodies below are relocated VERBATIM from
// the hub and the two invariants are preserved exactly:
//
// * F-034 (TOCTOU): `userAccount(for:)` does check-build-insert in ONE
// `userAccountsLock` span, returning the same cached instance per UUID whose
// keychain keys are immutable for its lifetime. Splitting the read/insert would
// let two instances exist for one UUID and reopen the 6-year race (PP-4020).
// * F-016 (ride-out): `currentUserAccount` writes `lastKnownCurrentUserAccount`
// under the lock on EVERY id-present resolution, and during the nil window
// returns that last-resolved instance (placeholder only on true fresh install)
// so consumers never observe `hasCredentials == false` on a signed-in account.
//
// A `final class` (not an actor): `currentUserAccount` is reached synchronously from
// the `@objc TPPUserAccountResolving` facade on `AccountsManager` (which stays the
// protocol witness) and from the non-async `currentAccount` setter — an actor would
// force `await` through the `@objc` conformance. The resolver is a plain internal
// collaborator; it is NOT `@objc` and does NOT conform to the protocol.
//
// `@unchecked Sendable` invariant: the only mutable state is `userAccounts` and
// `lastKnownCurrentUserAccount`, read/written exclusively under `userAccountsLock`
// (an immutable `NSLock`); `noAccountPlaceholder` is a `lazy var` resolved at most
// once on the fresh-install path and immutable thereafter (write-once);
// `currentAccountIdProvider` is an immutable `let` reading the internally
// thread-safe `UserDefaults` live on every call.
//
// There is NO shared-singleton credential fallback anywhere here — the only fallbacks
// are `lastKnownCurrentUserAccount` then `noAccountPlaceholder`. A shared-singleton
// safety-net is the PR #822 defect that caused spurious sign-in modals.
//
// Copyright © 2026 The Palace Project. All rights reserved.
//

import Foundation

/// Resolves per-library `TPPUserAccount` instances with immutable-key isolation and
/// the account-switch ride-out. Injected into `AccountsManager`, which keeps the
/// `@objc TPPUserAccountResolving` facades; tests construct it directly with a spy
/// `currentAccountIdProvider`.
final class AccountCredentialResolver: @unchecked Sendable {

/// Live read of the current library's UUID. MUST re-read on every call (not a
/// captured snapshot) — the ride-out below depends on observing the transient nil
/// window in real time.
private let currentAccountIdProvider: () -> String?

/// Cache of per-library `TPPUserAccount` instances. Each instance has immutable
/// keychain keys, eliminating the TOCTOU race that the singleton's mutable
/// `libraryUUID` pattern was subject to.
private var userAccounts = [String: TPPUserAccount]()
private let userAccountsLock = NSLock()

/// Last account returned from `currentUserAccount`. Used to ride out the brief
/// windows where `currentAccountId` is nil during an account switch — without this,
/// consumers observe a transiently-unauthenticated state on an account that IS
/// signed in, and fire spurious sign-in modals.
private var lastKnownCurrentUserAccount: TPPUserAccount?

/// Sentinel UUID for the "no account selected" placeholder. Not a real library
/// UUID — keychain reads for this instance return nil, so hasCredentials()
/// deterministically returns false.
private static let noAccountSentinelUUID = "__no_account_selected__"

/// Placeholder returned by `currentUserAccount` only on a truly fresh install
/// before any account has ever been selected. Lazily created so app launch doesn't
/// pay for a keychain-probed instance.
private lazy var noAccountPlaceholder: TPPUserAccount = TPPUserAccount(
libraryUUID: Self.noAccountSentinelUUID
)

init(currentAccountIdProvider: @escaping () -> String?) {
self.currentAccountIdProvider = currentAccountIdProvider
}

/// Returns a library-scoped `TPPUserAccount` instance. Creates and caches a new one
/// on first access for a given UUID.
func userAccount(for libraryUUID: String) -> TPPUserAccount {
userAccountsLock.lock()
defer { userAccountsLock.unlock() }
if let existing = userAccounts[libraryUUID] {
return existing
}
let account = TPPUserAccount(libraryUUID: libraryUUID)
userAccounts[libraryUUID] = account
return account
}

/// Convenience for the current library's user account.
///
/// Thread-safety note: `currentAccountId` can transiently be nil during an account
/// switch (the old id is cleared before the new id is assigned). If we blindly fell
/// back to a fresh/empty instance in that window, consumers like
/// MyBooksDownloadCenter would observe `hasCredentials == false` on an account that
/// IS signed in and fire a spurious login modal. We cache the last-resolved account
/// and return it during the nil window instead. The placeholder path only fires on a
/// true fresh-install state where no account has ever been selected.
var currentUserAccount: TPPUserAccount {
if let id = currentAccountIdProvider() {
let account = userAccount(for: id)
userAccountsLock.lock()
lastKnownCurrentUserAccount = account
userAccountsLock.unlock()
return account
}
userAccountsLock.lock()
let last = lastKnownCurrentUserAccount
userAccountsLock.unlock()
return last ?? noAccountPlaceholder
}
}
Loading
Loading