Files
Rocky e5a83843db Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
2026-08-22 16:33:18 +08:00

390 lines
13 KiB
Swift

// DeviceIntegrity.swift
// OSGKeyboard · HostSupport
//
// DeviceCheck and App Attest adapters with server-bound canonical payloads.
import CryptoKit
import DeviceCheck
import Foundation
public protocol DeviceCheckTokenProviding: Sendable {
var isSupported: Bool { get }
func generateToken() async throws -> Data
}
public protocol AppAttestProviding: Sendable {
var isSupported: Bool { get }
func generateKey() async throws -> String
func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data
func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data
}
public final class SystemDeviceCheckProvider: DeviceCheckTokenProviding, @unchecked Sendable {
private let device: DCDevice
public var isSupported: Bool {
device.isSupported
}
public init(device: DCDevice = .current) {
self.device = device
}
public func generateToken() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
device.generateToken { data, error in
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
}
}
}
}
}
public final class SystemAppAttestProvider: AppAttestProviding, @unchecked Sendable {
private let service: DCAppAttestService
public var isSupported: Bool {
service.isSupported
}
public init(service: DCAppAttestService = .shared) {
self.service = service
}
public func generateKey() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
service.generateKey { keyId, error in
if let keyId {
continuation.resume(returning: keyId)
} else {
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
}
}
}
}
public func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
service.attestKey(keyId, clientDataHash: clientDataHash) { object, error in
if let object {
continuation.resume(returning: object)
} else {
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
}
}
}
}
public func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
service.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
if let assertion {
continuation.resume(returning: assertion)
} else {
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
}
}
}
}
}
public struct DeviceIntegrityEvidence: Equatable, Sendable {
public let deviceCheckToken: String?
public let appAttest: AppAttestAssertion?
public init(deviceCheckToken: String?, appAttest: AppAttestAssertion?) {
self.deviceCheckToken = deviceCheckToken
self.appAttest = appAttest
}
}
public enum AppAttestCanonicalPayload {
/// Matches `AppAttestCanonicalPayload.appleSignIn` on the account server.
/// The final `\n` is part of the signed UTF-8 payload.
public static func appleSignIn(
challenge: String,
credential: AppleSignInCredential,
rawNonce: String
) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
}
let canonicalChallenge = challengeBytes.base64URLEncodedString()
let payload = """
osg-app-attest-v1
purpose=apple-sign-in
challenge=\(canonicalChallenge)
identity_token_sha256=\(digest(credential.identityToken))
authorization_code_sha256=\(digest(credential.authorizationCode))
nonce_sha256=\(digest(rawNonce))
"""
guard let data = payload.data(using: .utf8) else {
throw AccountAPIError.invalidResponse
}
return data
}
/// Matches the server's anonymous OOBE grant assertion payload byte for
/// byte. The final line feed is part of the signed UTF-8 value.
public static func oobeGrant(
challenge: String,
installationID: UUID,
keyID: String
) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
}
let canonicalChallenge = challengeBytes.base64URLEncodedString()
let payload = """
osg-app-attest-v1
purpose=oobe-gateway-grant
challenge=\(canonicalChallenge)
key_id=\(keyID)
installation_id=\(installationID.uuidString.lowercased())
scopes=ai,polish
features=ask_ai,clipboard_reply,clipboard_translate,voice_input
grant_ttl_seconds=1800
access_ttl_seconds=300
"""
guard let data = payload.data(using: .utf8) else {
throw AccountAPIError.invalidResponse
}
return data
}
static func challengeHash(_ challenge: String) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
}
return sha256(challengeBytes)
}
static func sha256(_ data: Data) -> Data {
Data(SHA256.hash(data: data))
}
private static func digest(_ value: String) -> String {
sha256(Data(value.utf8)).base64URLEncodedString()
}
}
public actor DeviceIntegrityCoordinator {
private let apiClient: AccountAPIClient
private let deviceCheck: any DeviceCheckTokenProviding
private let appAttest: any AppAttestProviding
private let keyStateStore: any AppAttestKeyStateStoring
public init(
apiClient: AccountAPIClient,
deviceCheck: any DeviceCheckTokenProviding = SystemDeviceCheckProvider(),
appAttest: any AppAttestProviding = SystemAppAttestProvider(),
keyStateStore: any AppAttestKeyStateStoring
) {
self.apiClient = apiClient
self.deviceCheck = deviceCheck
self.appAttest = appAttest
self.keyStateStore = keyStateStore
}
public func evidenceForAppleSignIn(
credential: AppleSignInCredential,
rawNonce: String
) async throws -> DeviceIntegrityEvidence {
async let deviceCheckToken = optionalDeviceCheckToken()
async let assertion = optionalAppleSignInAssertion(
credential: credential,
rawNonce: rawNonce
)
let evidence = await DeviceIntegrityEvidence(
deviceCheckToken: deviceCheckToken,
appAttest: assertion
)
if evidence.deviceCheckToken == nil,
evidence.appAttest == nil,
deviceCheck.isSupported || appAttest.isSupported {
throw AccountAPIError.integrityUnavailable
}
return evidence
}
public func clearLocalKeyState() async {
try? await keyStateStore.clearAppAttestKeyState()
}
public func makeOOBEGrantRequest(
installationID: UUID
) async throws -> OOBEGrantRequest {
try await makeOOBEGrantRequest(
installationID: installationID,
allowsKeyRecovery: true
)
}
private func optionalDeviceCheckToken() async -> String? {
try? await makeDeviceCheckToken()
}
private func optionalAppleSignInAssertion(
credential: AppleSignInCredential,
rawNonce: String
) async -> AppAttestAssertion? {
try? await makeAppleSignInAssertion(
credential: credential,
rawNonce: rawNonce
)
}
private func makeDeviceCheckToken() async throws -> String? {
guard deviceCheck.isSupported else { return nil }
do {
return try await deviceCheck.generateToken().base64EncodedString()
} catch {
throw AccountAPIError.integrityUnavailable
}
}
private func makeAppleSignInAssertion(
credential: AppleSignInCredential,
rawNonce: String,
allowsKeyRecovery: Bool = true
) async throws -> AppAttestAssertion? {
guard appAttest.isSupported else { return nil }
let keyId = try await registeredKeyId()
let challenge = try await apiClient.issueAppAttestChallenge(
purpose: .assertion,
keyId: keyId
)
let payload = try AppAttestCanonicalPayload.appleSignIn(
challenge: challenge.challenge,
credential: credential,
rawNonce: rawNonce
)
let assertion: Data
do {
assertion = try await appAttest.generateAssertion(
keyId,
clientDataHash: AppAttestCanonicalPayload.sha256(payload)
)
} catch where allowsKeyRecovery {
try? await keyStateStore.clearAppAttestKeyState()
return try await makeAppleSignInAssertion(
credential: credential,
rawNonce: rawNonce,
allowsKeyRecovery: false
)
} catch {
throw AccountAPIError.integrityUnavailable
}
return AppAttestAssertion(
keyId: keyId,
challengeId: challenge.challengeId,
challenge: challenge.challenge,
assertion: assertion.base64EncodedString()
)
}
private func makeOOBEGrantRequest(
installationID: UUID,
allowsKeyRecovery: Bool
) async throws -> OOBEGrantRequest {
guard appAttest.isSupported else {
throw AccountAPIError.integrityUnavailable
}
let keyID = try await registeredKeyId()
let challenge = try await apiClient.issueAppAttestChallenge(
purpose: .assertion,
keyId: keyID
)
let payload = try AppAttestCanonicalPayload.oobeGrant(
challenge: challenge.challenge,
installationID: installationID,
keyID: keyID
)
let assertion: Data
do {
assertion = try await appAttest.generateAssertion(
keyID,
clientDataHash: AppAttestCanonicalPayload.sha256(payload)
)
} catch where allowsKeyRecovery {
try? await keyStateStore.clearAppAttestKeyState()
return try await makeOOBEGrantRequest(
installationID: installationID,
allowsKeyRecovery: false
)
} catch {
throw AccountAPIError.integrityUnavailable
}
return OOBEGrantRequest(
installationId: installationID,
keyId: keyID,
challengeId: challenge.challengeId,
challenge: challenge.challenge,
assertion: assertion.base64EncodedString()
)
}
private func registeredKeyId(
allowsKeyRecovery: Bool = true
) async throws -> String {
do {
return try await registerKeyIfNeeded()
} catch where allowsKeyRecovery {
try? await keyStateStore.clearAppAttestKeyState()
return try await registeredKeyId(allowsKeyRecovery: false)
}
}
private func registerKeyIfNeeded() async throws -> String {
var state: AppAttestKeyState
do {
if let existing = try await keyStateStore.loadAppAttestKeyState() {
state = existing
} else {
let keyId = try await appAttest.generateKey()
state = AppAttestKeyState(keyId: keyId, isRegistered: false)
try await keyStateStore.saveAppAttestKeyState(state)
}
} catch let error as AccountAPIError {
throw error
} catch {
throw AccountAPIError.integrityUnavailable
}
if state.isRegistered {
return state.keyId
}
let challenge = try await apiClient.issueAppAttestChallenge(
purpose: .attestation,
keyId: state.keyId
)
let attestationObject: Data
do {
attestationObject = try await appAttest.attestKey(
state.keyId,
clientDataHash: AppAttestCanonicalPayload.challengeHash(challenge.challenge)
)
} catch {
throw AccountAPIError.integrityUnavailable
}
try await apiClient.submitAttestation(
challenge: challenge,
keyId: state.keyId,
attestationObject: attestationObject
)
state = AppAttestKeyState(keyId: state.keyId, isRegistered: true)
do {
try await keyStateStore.saveAppAttestKeyState(state)
} catch {
throw AccountAPIError.secureStorage
}
return state.keyId
}
}