Files
OSGKeyboard/OSGKeyboardShared/Features/ManagedGateway/GatewayGrantCredentialStore.swift
T
Rocky 498f407585 feat(account): add managed credits and cloud gateway
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
2026-08-20 11:43:21 +08:00

107 lines
3.7 KiB
Swift

// GatewayGrantCredentialStore.swift
// OSGKeyboard · Shared
//
// Shared Keychain storage dedicated to scope-limited gateway grants. Account
// session credentials intentionally never enter this service or access group item.
import Foundation
import Security
public protocol GatewayGrantCredentialStore: Sendable {
func load() async throws -> ManagedGatewayGrantCredentials?
func save(_ credentials: ManagedGatewayGrantCredentials) async throws
func delete() async throws
}
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
public enum StoreError: Error, Equatable, Sendable {
case unexpectedStatus(OSStatus)
case invalidStoredValue
}
private static let service = "com.osgkeyboard.gateway-grant"
private static let account = "scope-limited.active"
public init() {}
public func load() async throws -> ManagedGatewayGrantCredentials? {
var query = Self.baseQuery
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,
let value = try? Self.decoder.decode(
ManagedGatewayGrantCredentials.self,
from: data
) else {
throw StoreError.invalidStoredValue
}
return value
case errSecItemNotFound:
return nil
default:
throw StoreError.unexpectedStatus(status)
}
}
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
let data = try Self.encoder.encode(credentials)
let updateStatus = SecItemUpdate(
Self.baseQuery as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
var query = Self.baseQuery
query[kSecValueData as String] = data
// The extension can refresh after reboot without making account
// credentials readable; this item contains only the limited grant.
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(query as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw StoreError.unexpectedStatus(addStatus)
}
default:
throw StoreError.unexpectedStatus(updateStatus)
}
}
public func delete() async throws {
let status = SecItemDelete(Self.baseQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw StoreError.unexpectedStatus(status)
}
}
private static var baseQuery: [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kCFBooleanFalse!
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
#endif
return query
}
private static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}
private static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}