Files
OSGKeyboard/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift
T
Rocky 1ee032bdb9
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled
fix(account): merge durable session refresh
Preserve refresh idempotency and Apple credential revocation handling alongside the consolidated app updates.
2026-08-25 16:44:51 +08:00

217 lines
7.6 KiB
Swift

// HostPrivateAccountKeychain.swift
// OSGKeyboard · HostSupport
//
// Main-app-only storage for OSG account sessions and App Attest key state.
import Foundation
import OSLog
import Security
public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
public static let defaultService = "com.osgkeyboard.ios.account"
public static let hostBundleIdentifier = "com.osgkeyboard.ios"
public let service: String
public let accessGroup: String
public init(
service: String = Self.defaultService,
accessGroup: String
) throws {
let normalizedService = service.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedAccessGroup = accessGroup.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedService.isEmpty,
normalizedAccessGroup.hasSuffix(".\(Self.hostBundleIdentifier)"),
!normalizedAccessGroup.hasSuffix(".com.osgkeyboard.shared") else {
throw AccountAPIError.secureStorage
}
self.service = normalizedService
self.accessGroup = normalizedAccessGroup
}
/// The prefix is the signed App Identifier Prefix, including or excluding
/// its trailing period. It must come from host-app build configuration.
public static func hostApplication(appIdentifierPrefix: String) throws -> Self {
let prefix = appIdentifierPrefix.hasSuffix(".")
? appIdentifierPrefix
: "\(appIdentifierPrefix)."
return try Self(accessGroup: "\(prefix)\(hostBundleIdentifier)")
}
}
public actor HostPrivateAccountKeychain:
AccountSessionVault,
AppleUserIdentifierStoring,
AppAttestKeyStateStoring,
OOBEInstallationIDStoring {
private static let logger = Logger(
subsystem: HostPrivateAccountKeychainDescriptor.hostBundleIdentifier,
category: "account"
)
private enum Account {
static let session = "account.session"
static let refreshTransaction = "account.refresh-transaction"
static let appleUserIdentifier = "account.apple-user-identifier"
static let appAttestKeyState = "integrity.app-attest-key-state"
static let oobeInstallationID = "oobe.installation-id"
}
let descriptor: HostPrivateAccountKeychainDescriptor
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(descriptor: HostPrivateAccountKeychainDescriptor) {
self.descriptor = descriptor
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
}
public func loadSession() async throws -> AccountSession? {
do {
let session = try read(AccountSession.self, account: Account.session)
Self.logger.info(
"session keychain restore status=\(session == nil ? "not-found" : "found", privacy: .public)"
)
return session
} catch {
Self.logger.error("session keychain restore status=unavailable")
throw error
}
}
public func saveSession(_ session: AccountSession) async throws {
try write(session, account: Account.session)
}
public func clearSession() async throws {
try delete(account: Account.session)
}
public func beginRefreshTransaction(
refreshTokenDigest: String
) async throws -> AccountRefreshTransaction {
if let existing = try read(
AccountRefreshTransaction.self,
account: Account.refreshTransaction
), existing.refreshTokenDigest == refreshTokenDigest {
return existing
}
let transaction = AccountRefreshTransaction(
refreshTokenDigest: refreshTokenDigest,
operationId: UUID()
)
try write(transaction, account: Account.refreshTransaction)
return transaction
}
public func clearRefreshTransaction() async throws {
try delete(account: Account.refreshTransaction)
}
public func loadAppleUserIdentifier() async throws -> String? {
try read(String.self, account: Account.appleUserIdentifier)
}
public func saveAppleUserIdentifier(_ userIdentifier: String) async throws {
try write(userIdentifier, account: Account.appleUserIdentifier)
}
public func clearAppleUserIdentifier() async throws {
try delete(account: Account.appleUserIdentifier)
}
public func loadAppAttestKeyState() async throws -> AppAttestKeyState? {
try read(AppAttestKeyState.self, account: Account.appAttestKeyState)
}
public func saveAppAttestKeyState(_ state: AppAttestKeyState) async throws {
try write(state, account: Account.appAttestKeyState)
}
public func clearAppAttestKeyState() async throws {
try delete(account: Account.appAttestKeyState)
}
public func oobeInstallationID() async throws -> UUID {
if let existing = try read(UUID.self, account: Account.oobeInstallationID) {
return existing
}
let created = UUID()
try write(created, account: Account.oobeInstallationID)
return created
}
private func read<Value: Decodable>(_ type: Value.Type, account: String) throws -> Value? {
var query = baseQuery(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data else {
throw AccountAPIError.secureStorage
}
do {
return try decoder.decode(type, from: data)
} catch {
throw AccountAPIError.secureStorage
}
case errSecItemNotFound:
return nil
default:
Self.logger.error(
"keychain read failed account=\(account, privacy: .public) status=\(status, privacy: .public)"
)
throw AccountAPIError.secureStorage
}
}
private func write<Value: Encodable>(_ value: Value, account: String) throws {
let data: Data
do {
data = try encoder.encode(value)
} catch {
throw AccountAPIError.secureStorage
}
let query = baseQuery(account: account)
let updateStatus = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
throw AccountAPIError.secureStorage
}
var addQuery = query
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
guard SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess else {
throw AccountAPIError.secureStorage
}
}
private func delete(account: String) throws {
let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw AccountAPIError.secureStorage
}
}
private func baseQuery(account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: descriptor.service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: descriptor.accessGroup,
kSecAttrSynchronizable as String: kCFBooleanFalse!
]
}
}