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.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// The single HTTP exit for account, auth, and integrity traffic.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
public protocol AccountHTTPTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
@@ -36,6 +37,7 @@ public actor AccountAPIClient {
|
||||
case integrityChallenge
|
||||
case attest
|
||||
case assert
|
||||
case oobeGrant
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
@@ -66,12 +68,15 @@ public actor AccountAPIClient {
|
||||
return "/v1/integrity/attest"
|
||||
case .assert:
|
||||
return "/v1/integrity/assert"
|
||||
case .oobeGrant:
|
||||
return "/v1/oobe/grants"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RefreshOperation {
|
||||
let id: UUID
|
||||
let failedAccessToken: String
|
||||
let task: Task<AccountSession, Error>
|
||||
}
|
||||
|
||||
@@ -85,6 +90,9 @@ public actor AccountAPIClient {
|
||||
private var cachedSession: AccountSession?
|
||||
private var didLoadSession = false
|
||||
private var refreshOperation: RefreshOperation?
|
||||
private var invalidationContinuations: [
|
||||
UUID: AsyncStream<AccountSessionInvalidation>.Continuation
|
||||
] = [:]
|
||||
|
||||
public init(
|
||||
baseURL: URL = URL(string: "https://account.osglab.com")!,
|
||||
@@ -117,6 +125,18 @@ public actor AccountAPIClient {
|
||||
try await loadSessionIfNeeded()
|
||||
}
|
||||
|
||||
public func sessionInvalidations() -> AsyncStream<AccountSessionInvalidation> {
|
||||
let id = UUID()
|
||||
return AsyncStream { continuation in
|
||||
invalidationContinuations[id] = continuation
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
Task {
|
||||
await self?.removeInvalidationContinuation(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func accessTokenForAuthorizedRequest() async throws -> String {
|
||||
try await sessionForRequest().accessToken
|
||||
}
|
||||
@@ -184,7 +204,9 @@ public actor AccountAPIClient {
|
||||
)
|
||||
let retryResponse = try await send(retryRequest)
|
||||
if retryResponse.response.statusCode == 401 {
|
||||
try await clearSession()
|
||||
try await invalidateSession(
|
||||
ifAccessTokenMatches: replacement.accessToken
|
||||
)
|
||||
}
|
||||
return try validatedData(retryResponse)
|
||||
}
|
||||
@@ -273,6 +295,25 @@ public actor AccountAPIClient {
|
||||
return try decode(AppAssertionResponse.self, from: data).counter
|
||||
}
|
||||
|
||||
public func requestOOBEGrant(
|
||||
_ request: OOBEGrantRequest
|
||||
) async throws -> ManagedGatewayGrantCredentials {
|
||||
let data = try await perform(
|
||||
endpoint: .oobeGrant,
|
||||
body: try encode(request),
|
||||
requiresSession: false
|
||||
)
|
||||
do {
|
||||
let response = try Self.gatewayDecoder().decode(
|
||||
ManagedGatewayGrantTokenResponse.self,
|
||||
from: data
|
||||
)
|
||||
return response.credentials(receivedAt: now())
|
||||
} catch {
|
||||
throw AccountAPIError.decoding
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(
|
||||
endpoint: Endpoint,
|
||||
body: Data?,
|
||||
@@ -319,7 +360,9 @@ public actor AccountAPIClient {
|
||||
)
|
||||
let retryResponse = try await send(retryRequest)
|
||||
if retryResponse.response.statusCode == 401 {
|
||||
try await clearSession()
|
||||
try await invalidateSession(
|
||||
ifAccessTokenMatches: replacement.accessToken
|
||||
)
|
||||
}
|
||||
return try validatedData(retryResponse)
|
||||
}
|
||||
@@ -345,7 +388,9 @@ public actor AccountAPIClient {
|
||||
}
|
||||
let nowSeconds = Int64(now().timeIntervalSince1970)
|
||||
guard session.refreshTokenExpiresAtEpochSeconds > nowSeconds else {
|
||||
try await clearSession()
|
||||
try await invalidateSession(
|
||||
ifAccessTokenMatches: session.accessToken
|
||||
)
|
||||
throw AccountAPIError.unauthorized("The refresh token has expired.")
|
||||
}
|
||||
if session.accessTokenExpiresAtEpochSeconds <= nowSeconds + 30 {
|
||||
@@ -367,6 +412,7 @@ public actor AccountAPIClient {
|
||||
|
||||
let operation = RefreshOperation(
|
||||
id: UUID(),
|
||||
failedAccessToken: current.accessToken,
|
||||
task: Task {
|
||||
try await self.requestRefresh(using: current.refreshToken)
|
||||
}
|
||||
@@ -378,6 +424,18 @@ public actor AccountAPIClient {
|
||||
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
|
||||
do {
|
||||
let replacement = try await operation.task.value
|
||||
guard let current = try await loadSessionIfNeeded() else {
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
}
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
if current.accessToken != operation.failedAccessToken {
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
}
|
||||
return current
|
||||
}
|
||||
try await replaceSession(with: replacement)
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
@@ -387,8 +445,14 @@ public actor AccountAPIClient {
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
}
|
||||
if let current = try? await loadSessionIfNeeded(),
|
||||
current.accessToken != operation.failedAccessToken {
|
||||
return current
|
||||
}
|
||||
if shouldClearSession(afterRefreshError: error) {
|
||||
try? await clearSession()
|
||||
try? await invalidateSession(
|
||||
ifAccessTokenMatches: operation.failedAccessToken
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -451,6 +515,30 @@ public actor AccountAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func invalidateSession(ifAccessTokenMatches expectedAccessToken: String) async throws {
|
||||
guard let current = try await loadSessionIfNeeded(),
|
||||
current.accessToken == expectedAccessToken else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await clearSession()
|
||||
} catch {
|
||||
publishSessionInvalidation()
|
||||
throw error
|
||||
}
|
||||
publishSessionInvalidation()
|
||||
}
|
||||
|
||||
private func publishSessionInvalidation() {
|
||||
invalidationContinuations.values.forEach {
|
||||
$0.yield(.expired)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInvalidationContinuation(id: UUID) {
|
||||
invalidationContinuations[id] = nil
|
||||
}
|
||||
|
||||
private func makeRequest(
|
||||
endpoint: Endpoint,
|
||||
body: Data?,
|
||||
@@ -564,6 +652,29 @@ public actor AccountAPIClient {
|
||||
throw AccountAPIError.decoding
|
||||
}
|
||||
}
|
||||
|
||||
private static func gatewayDecoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let value = try container.decode(String.self)
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: value) {
|
||||
return date
|
||||
}
|
||||
let standard = ISO8601DateFormatter()
|
||||
standard.formatOptions = [.withInternetDateTime]
|
||||
guard let date = standard.date(from: value) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "Invalid ISO-8601 date"
|
||||
)
|
||||
}
|
||||
return date
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
|
||||
@@ -166,6 +166,31 @@ public struct AppAttestAssertion: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anonymous OOBE grant request. It intentionally contains no account session,
|
||||
/// requested scopes, or mutable feature claim; the server fixes policy to
|
||||
/// `polish` + `ai` after validating App Attest.
|
||||
public struct OOBEGrantRequest: Codable, Equatable, Sendable {
|
||||
public let installationId: UUID
|
||||
public let keyId: String
|
||||
public let challengeId: UUID
|
||||
public let challenge: String
|
||||
public let assertion: String
|
||||
|
||||
public init(
|
||||
installationId: UUID,
|
||||
keyId: String,
|
||||
challengeId: UUID,
|
||||
challenge: String,
|
||||
assertion: String
|
||||
) {
|
||||
self.installationId = installationId
|
||||
self.keyId = keyId
|
||||
self.challengeId = challengeId
|
||||
self.challenge = challenge
|
||||
self.assertion = assertion
|
||||
}
|
||||
}
|
||||
|
||||
public enum AppAttestChallengePurpose: String, Codable, Sendable {
|
||||
case attestation
|
||||
case assertion
|
||||
@@ -205,6 +230,10 @@ public protocol AppAttestKeyStateStoring: Sendable {
|
||||
func clearAppAttestKeyState() async throws
|
||||
}
|
||||
|
||||
public protocol OOBEInstallationIDStoring: Sendable {
|
||||
func oobeInstallationID() async throws -> UUID
|
||||
}
|
||||
|
||||
public enum AccountAPIError: Error, Equatable, Sendable {
|
||||
case invalidRequest(String)
|
||||
case unauthorized(String)
|
||||
@@ -222,6 +251,10 @@ public enum AccountAPIError: Error, Equatable, Sendable {
|
||||
case integrityUnavailable
|
||||
}
|
||||
|
||||
public enum AccountSessionInvalidation: Sendable {
|
||||
case expired
|
||||
}
|
||||
|
||||
extension AccountAPIError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
|
||||
@@ -128,6 +128,35 @@ public enum AppAttestCanonicalPayload {
|
||||
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
|
||||
@@ -187,6 +216,15 @@ public actor DeviceIntegrityCoordinator {
|
||||
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()
|
||||
}
|
||||
@@ -250,6 +288,47 @@ public actor DeviceIntegrityCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -38,10 +38,14 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateStoring {
|
||||
public actor HostPrivateAccountKeychain:
|
||||
AccountSessionVault,
|
||||
AppAttestKeyStateStoring,
|
||||
OOBEInstallationIDStoring {
|
||||
private enum Account {
|
||||
static let session = "account.session"
|
||||
static let appAttestKeyState = "integrity.app-attest-key-state"
|
||||
static let oobeInstallationID = "oobe.installation-id"
|
||||
}
|
||||
|
||||
let descriptor: HostPrivateAccountKeychainDescriptor
|
||||
@@ -78,6 +82,15 @@ public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateS
|
||||
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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// OOBEGrantProvisioningCoordinator.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Provisions anonymous, App-Attest-bound OOBE credentials without creating or
|
||||
// mutating an AccountSession.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
public actor OOBEGrantProvisioningCoordinator {
|
||||
private let apiClient: AccountAPIClient
|
||||
private let integrity: DeviceIntegrityCoordinator
|
||||
private let installationIDs: any OOBEInstallationIDStoring
|
||||
private let grants: OOBEGatewayGrantCoordinator
|
||||
|
||||
public init(
|
||||
apiClient: AccountAPIClient,
|
||||
integrity: DeviceIntegrityCoordinator,
|
||||
installationIDs: any OOBEInstallationIDStoring,
|
||||
grants: OOBEGatewayGrantCoordinator = OOBEGatewayGrantCoordinator()
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.integrity = integrity
|
||||
self.installationIDs = installationIDs
|
||||
self.grants = grants
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func provision() async throws -> ManagedGatewayGrantCredentials {
|
||||
let installationID = try await installationIDs.oobeInstallationID()
|
||||
let request = try await integrity.makeOOBEGrantRequest(
|
||||
installationID: installationID
|
||||
)
|
||||
let credentials = try await apiClient.requestOOBEGrant(request)
|
||||
try await grants.install(credentials)
|
||||
return credentials
|
||||
}
|
||||
|
||||
public func clear() async {
|
||||
try? await grants.clearGrant()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user