diff --git a/.env.example b/.env.example index b4525cc..07ee25c 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,8 @@ APPLE_TOKEN_URL=https://appleid.apple.com/auth/token APPLE_REVOKE_URL=https://appleid.apple.com/auth/revoke APPLE_INTEGRITY_ENVIRONMENT=development APP_ATTEST_CHALLENGE_TTL_SECONDS=300 +# Temporary production-device testing only; keep false for normal deployments. +ALLOW_DEVELOPMENT_APP_ATTEST=false # DeviceCheck reuses the configured Apple Team ID, Key ID and ES256 private key. # Prefer the newer Volcengine API key. The legacy app ID/access token pair is optional. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 670fb25..6f7c7f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,8 @@ jobs: with: context: . push: true + build-args: | + APP_BUILD_SHA=${{ github.sha }} tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} cache-from: type=gha diff --git a/Dockerfile b/Dockerfile index 0cc8e28..413285a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ ./gradlew --no-daemon --no-configuration-cache --stacktrace installDist FROM eclipse-temurin:21-jre-alpine +ARG APP_BUILD_SHA=unknown RUN addgroup -S -g 10001 app \ && adduser -S -D -H -u 10001 -G app -h /app app WORKDIR /app @@ -17,6 +18,7 @@ WORKDIR /app COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/ ENV HOME=/tmp \ + APP_BUILD_SHA=$APP_BUILD_SHA \ JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=/tmp -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError" USER 10001:10001 diff --git a/README.md b/README.md index eb38039..c821147 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,11 @@ receipt, assertion, and certificate-chain validation use the official Apple App Attestation Root CA bundled from Apple Certificate Authority. The server stores the validated public key, receipt, and strictly increasing assertion counter. +Production accepts only production App Attest AAGUIDs by default. For a time-bounded physical-device +test against the production service, set `ALLOW_DEVELOPMENT_APP_ATTEST=true` to admit development +AAGUIDs from registered development builds. Disable the flag again after testing; TestFlight and App +Store builds do not require it. + Request an `attestation` challenge after `generateKey`, then call `/attest` with the resulting CBOR object. For login assertions, request an `assertion` challenge and generate the assertion over SHA-256 of the canonical UTF-8 payload documented in `docs/openapi.yaml`. Challenges are single-use and expire diff --git a/compose.yaml b/compose.yaml index a905d0a..850d3d6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -52,6 +52,7 @@ services: APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM} APPLE_INTEGRITY_ENVIRONMENT: production APP_ATTEST_CHALLENGE_TTL_SECONDS: ${APP_ATTEST_CHALLENGE_TTL_SECONDS:-300} + ALLOW_DEVELOPMENT_APP_ATTEST: ${ALLOW_DEVELOPMENT_APP_ATTEST:-false} ENFORCE_DEVICE_CHECK: "true" ENFORCE_APP_ATTEST: "true" diff --git a/deploy/smoke-local.sh b/deploy/smoke-local.sh index fefa087..4eee5bc 100755 --- a/deploy/smoke-local.sh +++ b/deploy/smoke-local.sh @@ -258,8 +258,15 @@ grant_pattern = re.compile( re.IGNORECASE, ) expected = set() -for raw_line in open(sys.argv[1], encoding="utf-8"): - match = grant_pattern.match(raw_line.strip()) +with open(sys.argv[1], encoding="utf-8") as grants_file: + statements = grants_file.read().split(";") +for statement in statements: + normalized = " ".join( + line.strip() + for line in statement.splitlines() + if line.strip() and not line.lstrip().startswith("--") + ) + match = grant_pattern.match(f"{normalized};") if match: for privilege in match.group(1).split(","): expected.add((match.group(2).lower(), privilege.strip().upper())) @@ -470,9 +477,9 @@ WHERE version IS NOT NULL ORDER BY installed_rank; SQL )" -EXPECTED_MIGRATIONS=$'1:1\n2:1\n3:1\n4:1\n5:1\n6:1\n7:1\n8:1\n9:1\n10:1\n11:1\n12:1\n13:1\n14:1\n15:1\n16:1\n17:1' +EXPECTED_MIGRATIONS="$(seq 1 26 | awk '{ print $1 ":1" }')" [[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] || - fail "Flyway history was not exactly successful V1-V17" + fail "Flyway history was not exactly successful V1-V26" REFERRAL_REWARDS="$( mysql_root --batch --skip-column-names osg_account_smoke <<'SQL' SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits) diff --git a/deploy/smoke/runtime-grants.sql b/deploy/smoke/runtime-grants.sql index f818fb8..232a45f 100644 --- a/deploy/smoke/runtime-grants.sql +++ b/deploy/smoke/runtime-grants.sql @@ -19,6 +19,11 @@ GRANT SELECT ON osg_account_smoke.gateway_grants TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.gateway_complimentary_requests TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.oobe_subjects TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.oobe_gateway_grants TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.oobe_gateway_refresh_tokens TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.oobe_gateway_claims TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.oobe_provider_requests TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%'; @@ -60,6 +65,11 @@ GRANT INSERT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'% GRANT INSERT, UPDATE ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.gateway_complimentary_requests TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.oobe_subjects TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.oobe_gateway_grants TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.oobe_gateway_refresh_tokens TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.oobe_gateway_claims TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.oobe_provider_requests TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%'; diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 20caab5..7c3aa8a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -97,6 +97,10 @@ Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8 v3 WSS endpoint、资源 ID 和 API Key;DeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。 三方凭据分别创建、分别轮换,不得复用。 +生产默认仅接受 TestFlight 与 App Store 构建的 production App Attest。确需让已登记真机上的 +Xcode Development 构建连接生产服务时,可临时设置 +`ALLOW_DEVELOPMENT_APP_ATTEST=true`;完成测试后应立即恢复为 `false` 并重启服务。 + ## 5. 构建与启动 GitHub CI 在测试通过后发布私有镜像 diff --git a/docs/mysql-minimum-privileges.sql b/docs/mysql-minimum-privileges.sql index 7e598e1..307310e 100644 --- a/docs/mysql-minimum-privileges.sql +++ b/docs/mysql-minimum-privileges.sql @@ -31,6 +31,11 @@ GRANT SELECT ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.gateway_complimentary_requests TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.oobe_subjects TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.oobe_gateway_grants TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.oobe_gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.oobe_gateway_claims TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.oobe_provider_requests TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; @@ -72,6 +77,11 @@ GRANT INSERT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20 GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE, DELETE ON osg_account.gateway_complimentary_requests TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.oobe_subjects TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.oobe_gateway_grants TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.oobe_gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE, DELETE ON osg_account.oobe_gateway_claims TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.oobe_provider_requests TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d516903..8fc46b3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -381,6 +381,43 @@ paths: responses: "200": { description: Assertion counter advanced } default: { $ref: "#/components/responses/Error" } + /v1/oobe/grants: + post: + security: [] + summary: Create a short-lived anonymous OOBE gateway grant + description: | + Verifies an App Attest assertion bound to the installation and returns + credentials limited to the four one-time onboarding AI features. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateOobeGrantRequest" } + responses: + "201": + description: OOBE gateway credentials + content: + application/json: + schema: { $ref: "#/components/schemas/OobeGrantTokens" } + default: { $ref: "#/components/responses/GatewayError" } + /v1/oobe/grants/refresh: + post: + security: [] + summary: Rotate an anonymous OOBE refresh token + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RefreshOobeGrantRequest" } + responses: + "200": + description: Rotated OOBE gateway credentials + content: + application/json: + schema: { $ref: "#/components/schemas/OobeGrantTokens" } + default: { $ref: "#/components/responses/GatewayError" } /v1/gateway/catalog: get: summary: Return configured managed-provider capabilities @@ -441,9 +478,10 @@ paths: and output-budget policy from `capability` plus optional `taskKind`. It never infers task type from `input` or `context`, and clients cannot supply provider parameters. Search and tools are currently disabled. - An authenticated `oobe` purpose is accepted only for dictation polish. - The first successful request per account is complimentary; later attempts - fail without falling through to paid billing. + For account grants, `oobe` is accepted only for dictation polish and the + first successful request per account is complimentary. Anonymous OOBE + grants require a matching `oobeFeature` and allow one successful request + per feature. Later attempts fail without falling through to paid billing. parameters: - $ref: "#/components/parameters/RequestId" - name: capability @@ -2629,8 +2667,51 @@ components: type: ["string", "null"] enum: [oobe, null] description: | - Optional server-audited billing purpose. `oobe` is valid only with - `polish` and `dictation_polish`, and is complimentary once per account. + Optional server-audited billing purpose. Account grants accept `oobe` + only for complimentary dictation polish. Anonymous OOBE grants require + `oobe` together with an `oobeFeature`. + oobeFeature: + type: ["string", "null"] + enum: [voice_input, clipboard_translate, clipboard_reply, ask_ai, null] + description: | + Required for anonymous OOBE grants. The server validates that the + feature matches the requested capability and task kind, and allows + each feature to succeed only once per installation-bound subject. + CreateOobeGrantRequest: + type: object + additionalProperties: false + required: [challengeId, challenge, keyId, installationId, assertion] + properties: + challengeId: { type: string, format: uuid } + challenge: { type: string, description: Base64URL challenge returned by the integrity API } + keyId: { type: string, minLength: 1, maxLength: 256 } + installationId: { type: string, format: uuid } + assertion: { type: string, contentEncoding: base64 } + RefreshOobeGrantRequest: + type: object + additionalProperties: false + required: [refreshToken] + properties: + refreshToken: { type: string, minLength: 32, maxLength: 512 } + OobeGrantTokens: + type: object + additionalProperties: false + required: + [grantId, scopes, features, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt] + properties: + grantId: { type: string, format: uuid } + scopes: + type: array + uniqueItems: true + items: { type: string, enum: [polish, ai] } + features: + type: array + uniqueItems: true + items: { type: string, enum: [voice_input, clipboard_translate, clipboard_reply, ask_ai] } + accessToken: { type: string } + accessExpiresAt: { type: string, format: date-time } + refreshToken: { type: string } + refreshExpiresAt: { type: string, format: date-time } CreateGatewayGrantRequest: type: object additionalProperties: false diff --git a/src/main/kotlin/com/osglab/account/Application.kt b/src/main/kotlin/com/osglab/account/Application.kt index 203c6e9..92f227d 100644 --- a/src/main/kotlin/com/osglab/account/Application.kt +++ b/src/main/kotlin/com/osglab/account/Application.kt @@ -131,6 +131,11 @@ import com.osglab.account.features.inviteweb.InviteWebConfig import com.osglab.account.features.inviteweb.InviteOpenRecorder import com.osglab.account.features.inviteweb.ReferralLookupPort import com.osglab.account.features.inviteweb.configureInviteWebRoutes +import com.osglab.account.features.oobe.ExposedOobeRepository +import com.osglab.account.features.oobe.OobeGrantService +import com.osglab.account.features.oobe.OobeRepository +import com.osglab.account.features.oobe.OobeTokenSettings +import com.osglab.account.features.oobe.oobeRoutes import com.osglab.account.features.referrals.routes.referralRoutes import com.osglab.account.features.referrals.services.ReferralOperations import com.osglab.account.features.referrals.services.ReferralService @@ -329,6 +334,7 @@ fun Application.module() { healthRoutes(koin.get()) rateLimit(AUTH_RATE_LIMIT) { authRoutes(koin.get()) + oobeRoutes(koin.get()) } rateLimit(ACCOUNT_RATE_LIMIT) { accountRoutes(koin.get()) @@ -652,8 +658,27 @@ fun accountServerModule(config: AppConfig): Module = module { maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays), ) } + single { ExposedOobeRepository(get()) } + single { + OobeTokenSettings( + issuer = config.session.issuer, + audience = "${config.session.audience}-gateway", + accessTokenHmacSecret = deriveGatewaySecret( + config.session.hmacSecret, + "oobe-gateway-access", + ), + refreshTokenHmacSecret = deriveGatewaySecret( + config.session.hmacSecret, + "oobe-gateway-refresh", + ), + ) + } + single { OobeGrantService(get(), get(), get()) } single { GatewayGrantService(get(), get()) } - single { GatewayBearerIdentity(get()) } + single { + val oobeGrants = get() + GatewayBearerIdentity(get(), oobeGrants::authenticate) + } single { CreditReservationAdapter( creditService = get(), @@ -664,7 +689,7 @@ fun accountServerModule(config: AppConfig): Module = module { single { ProviderCatalog(configuredProviders(config, get())) } - single { GatewayService(get(), get(), get(), get(), get()) } + single { GatewayService(get(), get(), get(), get(), get(), get()) } single { GatewayReconciliationService(get(), get()) } single { InviteWebConfig( diff --git a/src/main/kotlin/com/osglab/account/config/AppConfig.kt b/src/main/kotlin/com/osglab/account/config/AppConfig.kt index 27ed743..cbbffc0 100644 --- a/src/main/kotlin/com/osglab/account/config/AppConfig.kt +++ b/src/main/kotlin/com/osglab/account/config/AppConfig.kt @@ -148,6 +148,10 @@ data class AppConfig( if (production) "production" else "development", ), ), + allowDevelopmentAppAttest = config.booleanOrDefault( + "app.integrity.allowDevelopmentAppAttest", + false, + ), challengeLifetimeSeconds = config.positiveLong( "app.integrity.challengeLifetimeSeconds", 300, @@ -449,6 +453,7 @@ data class IntegrityConfig( val deviceCheckPolicy: IntegrityPolicy, val appAttestPolicy: IntegrityPolicy, val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT, + val allowDevelopmentAppAttest: Boolean = false, val challengeLifetimeSeconds: Long = 300, val appAttestTeamId: String = APP_ATTEST_TEAM_ID, val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID, diff --git a/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt b/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt index 5e92959..ea3b1fd 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt @@ -31,6 +31,26 @@ enum class GatewayRequestPurpose { OOBE, } +enum class GatewaySubjectType { + ACCOUNT, + OOBE, +} + +@Serializable +enum class OobeFeature { + @SerialName("voice_input") + VOICE_INPUT, + + @SerialName("clipboard_translate") + CLIPBOARD_TRANSLATE, + + @SerialName("clipboard_reply") + CLIPBOARD_REPLY, + + @SerialName("ask_ai") + ASK_AI, +} + @Serializable enum class UsageMeter { @SerialName("llm_token") @@ -50,10 +70,14 @@ data class GatewayPrincipal( // Callers must grant capabilities explicitly. An identity with omitted // scopes is intentionally unable to invoke a managed provider. val scopes: Set = emptySet(), + val subjectType: GatewaySubjectType = GatewaySubjectType.ACCOUNT, ) { // Kept as a compatibility name for the existing account-scoped persistence. val accountId: String get() = userId + + val isOobe: Boolean + get() = subjectType == GatewaySubjectType.OOBE } typealias GatewaySubject = GatewayPrincipal @@ -68,6 +92,7 @@ data class TextGatewayRequest( val requestSource: GatewayRequestSource? = null, val taskKind: GatewayTaskKind? = null, val requestPurpose: GatewayRequestPurpose? = null, + val oobeFeature: OobeFeature? = null, ) @Serializable @@ -176,6 +201,7 @@ data class TextProviderRequest( val stream: Boolean, override val requestSource: GatewayRequestSource? = null, override val requestPurpose: GatewayRequestPurpose? = null, + val oobeFeature: OobeFeature? = null, ) : ProviderRequest data class AsrProviderRequest( diff --git a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt index c9aa2d9..28a88ae 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt @@ -31,6 +31,8 @@ import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidEx import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException import com.osglab.account.features.gateway.services.GatewayService import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver +import com.osglab.account.features.oobe.OobeFeatureAlreadyUsedException +import com.osglab.account.features.oobe.OobeRequestAlreadyClaimedException import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -150,7 +152,15 @@ fun Route.configureGatewayRoutes( get("/catalog") { val requestId = call.gatewayRequestId() - call.requireSubject(gatewayIdentity, requestId) ?: return@get + val subject = call.requireSubject(gatewayIdentity, requestId) ?: return@get + if (subject.isOobe) { + return@get call.respondGatewayError( + HttpStatusCode.Forbidden, + "oobe_request_required", + "OOBE tokens are limited to OOBE LLM requests", + requestId, + ) + } call.respond(GatewayCatalogResponse(service.catalog())) } @@ -276,6 +286,7 @@ fun Route.configureGatewayRoutes( stream = body.stream, requestSource = body.requestSource, requestPurpose = body.requestPurpose, + oobeFeature = body.oobeFeature, ) if (body.stream) { @@ -444,6 +455,20 @@ private suspend fun ApplicationCall.respondGatewayFailure( requestId, ) + is OobeFeatureAlreadyUsedException -> respondGatewayError( + HttpStatusCode.Conflict, + "oobe_feature_already_used", + "This OOBE feature has already been used successfully", + requestId, + ) + + is OobeRequestAlreadyClaimedException -> respondGatewayError( + HttpStatusCode.Conflict, + "oobe_request_replayed", + "This OOBE request ID has already been used", + requestId, + ) + is GatewayBodyTooLargeException -> respondGatewayError( HttpStatusCode.PayloadTooLarge, "request_too_large", diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayGrantService.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayGrantService.kt index 0750dbe..09cce7b 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayGrantService.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayGrantService.kt @@ -226,6 +226,7 @@ class GatewayGrantService( class GatewayBearerIdentity( private val grants: GatewayGrantService, + private val authenticateOobe: suspend (String) -> GatewayPrincipal? = { null }, ) : GatewayAccessTokenPort { override suspend fun resolve(call: ApplicationCall): GatewayPrincipal? { val token = call.request.headers[HttpHeaders.Authorization] @@ -234,7 +235,7 @@ class GatewayBearerIdentity( ?.trim() ?.takeIf { it.isNotEmpty() && it.length <= MAX_ACCESS_TOKEN_CHARS } ?: return null - return grants.authenticate(token) + return grants.authenticate(token) ?: authenticateOobe(token) } private companion object { diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt index 419f76e..44a0f08 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt @@ -21,10 +21,16 @@ import com.osglab.account.features.gateway.ports.ProviderUsageEstimate import com.osglab.account.features.gateway.ports.ProviderRequestMetadata import com.osglab.account.features.gateway.providers.GatewayProvider import com.osglab.account.features.gateway.providers.ProviderCatalog +import com.osglab.account.features.oobe.OobeContract +import com.osglab.account.features.oobe.OobeFeatureAlreadyUsedException +import com.osglab.account.features.oobe.OobeProviderRequest +import com.osglab.account.features.oobe.OobeRepository +import com.osglab.account.features.oobe.OobeRequestClaim import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import java.time.Clock import kotlin.time.TimeSource class GatewayService( @@ -33,9 +39,11 @@ class GatewayService( private val grants: GatewayGrantPort, private val usageRecords: GatewayUsagePort, private val complimentaryRequests: ComplimentaryRequestPort = NoComplimentaryRequests, + private val oobeRequests: OobeRepository? = null, private val usageEstimator: GatewayUsageEstimator = ConservativeGatewayUsageEstimator, private val llmProviderTimeoutMillis: Long = 120_000L, private val asrProviderTimeoutMillis: Long = 360_000L, + private val clock: Clock = Clock.systemUTC(), ) { init { require(llmProviderTimeoutMillis > 0) @@ -59,14 +67,39 @@ class GatewayService( if (request.capability !in subject.scopes) { throw GatewayAccessDeniedException(request.capability) } - if (!grants.isAllowed(subject.accountId, request.capability)) { + if (!subject.isOobe && request is TextProviderRequest && request.oobeFeature != null) { + throw GatewayAccessDeniedException(request.capability) + } + if (subject.isOobe) { + validateAnonymousOobeRequest(request) + } else if (!grants.isAllowed(subject.accountId, request.capability)) { throw GatewayAccessDeniedException(request.capability) } val provider = catalog.providerFor(request) val estimate = usageEstimator.estimate(request) validateEstimate(request, estimate) - val complimentaryClaim = request.requestPurpose?.let { purpose -> + val oobeClaim = if (subject.isOobe) { + val textRequest = request as TextProviderRequest + val feature = requireNotNull(textRequest.oobeFeature) + val now = clock.instant() + requireNotNull(oobeRequests).claim( + OobeProviderRequest( + subjectId = subject.userId, + grantId = requireNotNull(subject.grantId), + feature = feature, + requestId = request.requestId, + providerId = provider.descriptor.id, + capability = request.capability, + purpose = GatewayRequestPurpose.OOBE, + ), + expiresAt = now.plus(OOBE_CLAIM_TTL), + now = now, + ) ?: throw OobeFeatureAlreadyUsedException(feature) + } else { + null + } + val complimentaryClaim = request.requestPurpose?.takeUnless { subject.isOobe }?.let { purpose -> validateComplimentaryRequest(request, purpose) complimentaryRequests.claim( accountId = subject.accountId, @@ -75,7 +108,7 @@ class GatewayService( requestId = request.requestId, ) ?: throw ComplimentaryRequestUnavailableException(purpose) } - val reservation = if (complimentaryClaim == null) { + val reservation = if (complimentaryClaim == null && oobeClaim == null) { credits.reserve( accountId = subject.accountId, estimate = estimate, @@ -85,38 +118,45 @@ class GatewayService( null } try { - usageRecords.claim( - ProviderRequestMetadata( - requestId = request.requestId, - accountId = subject.accountId, - reservationId = reservation?.id, - providerId = provider.descriptor.id, - capability = request.capability, - requestSource = request.requestSource, - requestPurpose = request.requestPurpose, - ), - ) + if (!subject.isOobe) { + usageRecords.claim( + ProviderRequestMetadata( + requestId = request.requestId, + accountId = subject.accountId, + reservationId = reservation?.id, + providerId = provider.descriptor.id, + capability = request.capability, + requestSource = request.requestSource, + requestPurpose = request.requestPurpose, + ), + ) + } } catch (replay: GatewayRequestAlreadyClaimedException) { // The existing claim owns the reservation. Releasing it here would // refund an in-flight or completed paid request. Complimentary // claims are newly acquired above and must not remain stranded. if (complimentaryClaim != null) { - releaseAfterFailure(null, complimentaryClaim, replay) + releaseAfterFailure(null, complimentaryClaim, oobeClaim, replay) } throw replay } catch (failure: Throwable) { - releaseAfterFailure(reservation, complimentaryClaim, failure) + releaseAfterFailure(reservation, complimentaryClaim, oobeClaim, failure) throw failure } try { - usageRecords.markStarted(subject.accountId, request.requestId) + if (oobeClaim != null) { + requireNotNull(oobeRequests).markStarted(oobeClaim) + } else { + usageRecords.markStarted(subject.accountId, request.requestId) + } } catch (failure: Throwable) { releaseAndRecord( subject.accountId, request.requestId, reservation, complimentaryClaim, + oobeClaim, failure, ) throw failure @@ -129,6 +169,7 @@ class GatewayService( estimate, reservation, complimentaryClaim, + oobeClaim, ) } @@ -172,6 +213,10 @@ class GatewayService( // Once upstream has completed, cancellation must not interrupt durable // metering. The reservation remains frozen if any settlement step fails. withContext(NonCancellable) { + if (prepared.oobeClaim != null) { + settleOobe(prepared, usage) + return@withContext + } if (prepared.complimentaryClaim != null) { settleComplimentary(prepared, usage) return@withContext @@ -214,6 +259,7 @@ class GatewayService( prepared.request.requestId, prepared.reservation, prepared.complimentaryClaim, + prepared.oobeClaim, failure, ) } @@ -224,11 +270,15 @@ class GatewayService( failure: Throwable, ) { runCatching { - usageRecords.markManualReview( - prepared.subject.accountId, - prepared.request.requestId, - errorCode, - ) + if (prepared.oobeClaim != null) { + requireNotNull(oobeRequests).markManualReview(prepared.oobeClaim, errorCode) + } else { + usageRecords.markManualReview( + prepared.subject.accountId, + prepared.request.requestId, + errorCode, + ) + } }.onFailure(failure::addSuppressed) } @@ -237,14 +287,20 @@ class GatewayService( requestId: String, reservation: CreditReservation?, complimentaryClaim: ComplimentaryRequestClaim?, + oobeClaim: OobeRequestClaim?, failure: Throwable, ): Unit = withContext(NonCancellable) { - val released = if (complimentaryClaim != null) { - runCatching { complimentaryRequests.release(complimentaryClaim) } - } else { - runCatching { credits.release(requireNotNull(reservation).id) } + val released = when { + oobeClaim != null -> runCatching { + requireNotNull(oobeRequests).release( + oobeClaim, + failure::class.simpleName ?: "provider_error", + ) + } + complimentaryClaim != null -> runCatching { complimentaryRequests.release(complimentaryClaim) } + else -> runCatching { credits.release(requireNotNull(reservation).id) } } - if (released.isSuccess) { + if (released.isSuccess && oobeClaim == null) { runCatching { usageRecords.markReleased( accountId, @@ -252,11 +308,17 @@ class GatewayService( failure::class.simpleName ?: "provider_error", ) }.onFailure(failure::addSuppressed) - } else { + } else if (released.isFailure) { released.exceptionOrNull()?.let(failure::addSuppressed) - runCatching { - usageRecords.markManualReview(accountId, requestId, "release_pending") - }.onFailure(failure::addSuppressed) + if (oobeClaim != null) { + runCatching { + requireNotNull(oobeRequests).markManualReview(oobeClaim, "release_pending") + }.onFailure(failure::addSuppressed) + } else { + runCatching { + usageRecords.markManualReview(accountId, requestId, "release_pending") + }.onFailure(failure::addSuppressed) + } } } @@ -284,6 +346,19 @@ class GatewayService( } } + private suspend fun settleOobe( + prepared: PreparedGatewayRequest, + usage: ProviderUsage, + ) { + val claim = requireNotNull(prepared.oobeClaim) + runCatching { requireNotNull(oobeRequests).consume(claim, usage) } + .onFailure { + runCatching { + requireNotNull(oobeRequests).markManualReview(claim, "oobe_consume_pending") + } + } + } + private fun validateUsage(usage: ProviderUsage, estimate: ProviderUsageEstimate) { if (usage.meter != estimate.meter) { throw GatewayUsagePolicyException("Provider usage meter differs from the reservation") @@ -352,15 +427,33 @@ class GatewayService( } } + private fun validateAnonymousOobeRequest(request: ProviderRequest) { + require(request is TextProviderRequest) { "OOBE tokens support only LLM requests" } + require(request.requestPurpose == GatewayRequestPurpose.OOBE) { + "OOBE tokens require requestPurpose=oobe" + } + val feature = requireNotNull(request.oobeFeature) { "OOBE tokens require oobeFeature" } + val policy = OobeContract.policy(feature) + require(request.capability == policy.capability && request.executionPolicy.taskKind == policy.taskKind) { + "oobeFeature does not match capability and taskKind" + } + } + private suspend fun releaseAfterFailure( reservation: CreditReservation?, complimentaryClaim: ComplimentaryRequestClaim?, + oobeClaim: OobeRequestClaim?, failure: Throwable, ): Unit = withContext(NonCancellable) { - val released = if (complimentaryClaim != null) { - runCatching { complimentaryRequests.release(complimentaryClaim) } - } else { - runCatching { credits.release(requireNotNull(reservation).id) } + val released = when { + oobeClaim != null -> runCatching { + requireNotNull(oobeRequests).release( + oobeClaim, + failure::class.simpleName ?: "provider_error", + ) + } + complimentaryClaim != null -> runCatching { complimentaryRequests.release(complimentaryClaim) } + else -> runCatching { credits.release(requireNotNull(reservation).id) } } released .onFailure(failure::addSuppressed) @@ -368,6 +461,7 @@ class GatewayService( private companion object { val PROVIDER_REQUEST_ID = Regex("[A-Za-z0-9._:-]{8,64}") + val OOBE_CLAIM_TTL: java.time.Duration = java.time.Duration.ofMinutes(15) } } @@ -378,6 +472,7 @@ data class PreparedGatewayRequest( val estimate: ProviderUsageEstimate, val reservation: CreditReservation?, val complimentaryClaim: ComplimentaryRequestClaim?, + val oobeClaim: OobeRequestClaim?, ) class GatewayReconciliationService( diff --git a/src/main/kotlin/com/osglab/account/features/health/HealthRoutes.kt b/src/main/kotlin/com/osglab/account/features/health/HealthRoutes.kt index b358e9d..5e8f2b1 100644 --- a/src/main/kotlin/com/osglab/account/features/health/HealthRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/health/HealthRoutes.kt @@ -8,22 +8,35 @@ import io.ktor.server.routing.get import io.ktor.server.routing.route import kotlinx.serialization.Serializable -fun Route.healthRoutes(databaseFactory: DatabaseFactory) { +fun Route.healthRoutes( + databaseFactory: DatabaseFactory, + buildSha: String = System.getenv("APP_BUILD_SHA") + ?.takeIf(BUILD_SHA::matches) + ?: "unknown", +) { route("/health") { get("/live") { - call.respond(HealthResponse(status = "UP")) + call.respond(HealthResponse(status = "UP", buildSha = buildSha)) } get("/ready") { val databaseReady = databaseFactory.isReady() if (databaseReady) { - call.respond(HealthResponse(status = "UP")) + call.respond(HealthResponse(status = "UP", buildSha = buildSha)) } else { - call.respond(HttpStatusCode.ServiceUnavailable, HealthResponse(status = "DOWN")) + call.respond( + HttpStatusCode.ServiceUnavailable, + HealthResponse(status = "DOWN", buildSha = buildSha), + ) } } } } @Serializable -private data class HealthResponse(val status: String) +private data class HealthResponse( + val status: String, + val buildSha: String, +) + +private val BUILD_SHA = Regex("[0-9a-f]{40}") diff --git a/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt b/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt index 7c244c1..0213b76 100644 --- a/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt +++ b/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt @@ -170,9 +170,18 @@ class LibraryAppAttestCrypto( private val rpIdHash = sha256( "${config.appAttestTeamId}.${config.appAttestBundleId}".toByteArray(Charsets.UTF_8), ) - private val expectedAaguid = when (config.appleEnvironment) { - AppleServiceEnvironment.DEVELOPMENT -> DEVELOPMENT_AAGUID - AppleServiceEnvironment.PRODUCTION -> PRODUCTION_AAGUID + private val allowedAaguids = buildList { + add( + when (config.appleEnvironment) { + AppleServiceEnvironment.DEVELOPMENT -> DEVELOPMENT_AAGUID + AppleServiceEnvironment.PRODUCTION -> PRODUCTION_AAGUID + }, + ) + if (config.allowDevelopmentAppAttest && + config.appleEnvironment == AppleServiceEnvironment.PRODUCTION + ) { + add(DEVELOPMENT_AAGUID) + } } override suspend fun validateAttestation( @@ -197,7 +206,7 @@ class LibraryAppAttestCrypto( if (authenticatorData.signCount != 0L) { throw AppAttestRejectedException("App Attest attestation counter must start at zero") } - if (!MessageDigest.isEqual(authenticatorData.aaguid, expectedAaguid)) { + if (allowedAaguids.none { MessageDigest.isEqual(authenticatorData.aaguid, it) }) { throw AppAttestRejectedException("App Attest AAGUID does not match the configured environment") } val decodedKeyId = decodeKeyId(keyId) diff --git a/src/main/kotlin/com/osglab/account/features/oobe/ExposedOobeRepository.kt b/src/main/kotlin/com/osglab/account/features/oobe/ExposedOobeRepository.kt new file mode 100644 index 0000000..1f1952d --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/oobe/ExposedOobeRepository.kt @@ -0,0 +1,388 @@ +package com.osglab.account.features.oobe + +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.gateway.models.ProviderUsage +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.lessEq +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.insertIgnore +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.time.Instant +import java.time.Clock + +private object OobeSubjectsTable : Table("oobe_subjects") { + val id = varchar("id", 36) + val keyId = varchar("key_id", 128) + val installationHash = char("installation_hash", 64) + val createdAt = timestamp("created_at") + val updatedAt = timestamp("updated_at") + override val primaryKey = PrimaryKey(id) +} + +private object OobeGrantsTable : Table("oobe_gateway_grants") { + val id = varchar("id", 36) + val subjectId = varchar("subject_id", 36) + val expiresAt = timestamp("expires_at") + val revokedAt = timestamp("revoked_at").nullable() + val createdAt = timestamp("created_at") + val updatedAt = timestamp("updated_at") + override val primaryKey = PrimaryKey(id) +} + +private object OobeRefreshTokensTable : Table("oobe_gateway_refresh_tokens") { + val id = varchar("id", 36) + val grantId = varchar("grant_id", 36) + val familyId = varchar("family_id", 36) + val tokenHash = char("token_hash", 64) + val replacedById = varchar("replaced_by_id", 36).nullable() + val rotationIdempotencyKey = varchar("rotation_idempotency_key", 128).nullable() + val expiresAt = timestamp("expires_at") + val revokedAt = timestamp("revoked_at").nullable() + val reuseDetectedAt = timestamp("reuse_detected_at").nullable() + val createdAt = timestamp("created_at") + override val primaryKey = PrimaryKey(id) +} + +private object OobeClaimsTable : Table("oobe_gateway_claims") { + val subjectId = varchar("subject_id", 36) + val feature = varchar("feature", 32) + val requestId = varchar("request_id", 64) + val status = varchar("status", 16) + val expiresAt = timestamp("expires_at") + val createdAt = timestamp("created_at") + val updatedAt = timestamp("updated_at") + override val primaryKey = PrimaryKey(subjectId, feature) +} + +private object OobeProviderRequestsTable : Table("oobe_provider_requests") { + val subjectId = varchar("subject_id", 36) + val requestId = varchar("request_id", 64) + val grantId = varchar("grant_id", 36) + val feature = varchar("feature", 32) + val providerId = varchar("provider_id", 64) + val capability = varchar("capability", 32) + val requestPurpose = varchar("request_purpose", 32) + val status = varchar("status", 24) + val providerRequestId = varchar("provider_request_id", 128).nullable() + val usageMeter = varchar("usage_meter", 32).nullable() + val usageUnits = long("usage_units").nullable() + val usageInputUnits = long("usage_input_units").nullable() + val usageOutputUnits = long("usage_output_units").nullable() + val serverDurationMillis = long("server_duration_millis").nullable() + val errorCode = varchar("error_code", 96).nullable() + val createdAt = timestamp("created_at") + val completedAt = timestamp("completed_at").nullable() + override val primaryKey = PrimaryKey(subjectId, requestId) +} + +class ExposedOobeRepository( + private val databaseFactory: DatabaseFactory, + private val clock: Clock = Clock.systemUTC(), +) : OobeRepository { + override suspend fun findOrCreateSubject( + keyId: String, + installationHash: String, + subjectId: String, + now: Instant, + ): OobeSubject = databaseFactory.query { + OobeSubjectsTable.insertIgnore { + it[id] = subjectId + it[OobeSubjectsTable.keyId] = keyId + it[OobeSubjectsTable.installationHash] = installationHash + it[createdAt] = now + it[updatedAt] = now + } + OobeSubjectsTable.selectAll() + .where { OobeSubjectsTable.keyId eq keyId } + .single() + .also { + require(it[OobeSubjectsTable.installationHash] == installationHash) { + "App Attest key is already bound to another installation" + } + } + .let { + OobeSubject( + id = it[OobeSubjectsTable.id], + keyId = it[OobeSubjectsTable.keyId], + installationHash = it[OobeSubjectsTable.installationHash], + ) + } + } + + override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh = + databaseFactory.query { + OobeGrantsTable.insert { + it[id] = grant.grant.id + it[subjectId] = grant.grant.subjectId + it[expiresAt] = grant.grant.expiresAt + it[createdAt] = now + it[updatedAt] = now + } + OobeRefreshTokensTable.insert { + it[id] = grant.refreshTokenId + it[grantId] = grant.grant.id + it[familyId] = grant.refreshFamilyId + it[tokenHash] = grant.refreshTokenHash + it[expiresAt] = minOf(grant.refreshExpiresAt, grant.grant.expiresAt) + it[createdAt] = now + } + StoredOobeRefresh( + grant = grant.grant, + tokenId = grant.refreshTokenId, + familyId = grant.refreshFamilyId, + expiresAt = minOf(grant.refreshExpiresAt, grant.grant.expiresAt), + ) + } + + override suspend fun rotateRefresh( + currentTokenHash: String, + rotationIdempotencyKey: String, + newTokenId: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): OobeRefreshRotationResult = databaseFactory.query { + val current = OobeRefreshTokensTable.selectAll() + .where { OobeRefreshTokensTable.tokenHash eq currentTokenHash } + .forUpdate() + .singleOrNull() + ?: return@query OobeRefreshRotationResult.Invalid + val grant = OobeGrantsTable.selectAll() + .where { OobeGrantsTable.id eq current[OobeRefreshTokensTable.grantId] } + .forUpdate() + .single() + + current[OobeRefreshTokensTable.replacedById]?.let { replacementId -> + if (current[OobeRefreshTokensTable.rotationIdempotencyKey] == rotationIdempotencyKey) { + val replacement = OobeRefreshTokensTable.selectAll() + .where { OobeRefreshTokensTable.id eq replacementId } + .single() + return@query OobeRefreshRotationResult.Rotated( + replacement.toStoredRefresh(grant.toOobeGrant()), + ) + } + OobeRefreshTokensTable.update({ + OobeRefreshTokensTable.familyId eq current[OobeRefreshTokensTable.familyId] + }) { + it[revokedAt] = now + } + OobeRefreshTokensTable.update({ OobeRefreshTokensTable.id eq current[OobeRefreshTokensTable.id] }) { + it[reuseDetectedAt] = now + } + OobeGrantsTable.update({ OobeGrantsTable.id eq grant[OobeGrantsTable.id] }) { + it[revokedAt] = now + it[updatedAt] = now + } + return@query OobeRefreshRotationResult.ReuseDetected + } + + if (current[OobeRefreshTokensTable.revokedAt] != null || + !current[OobeRefreshTokensTable.expiresAt].isAfter(now) || + grant[OobeGrantsTable.revokedAt] != null || + !grant[OobeGrantsTable.expiresAt].isAfter(now) + ) { + return@query OobeRefreshRotationResult.Invalid + } + val expiresAt = minOf(newExpiresAt, grant[OobeGrantsTable.expiresAt]) + OobeRefreshTokensTable.insert { + it[id] = newTokenId + it[grantId] = current[OobeRefreshTokensTable.grantId] + it[familyId] = current[OobeRefreshTokensTable.familyId] + it[tokenHash] = newTokenHash + it[OobeRefreshTokensTable.expiresAt] = expiresAt + it[createdAt] = now + } + OobeRefreshTokensTable.update({ OobeRefreshTokensTable.id eq current[OobeRefreshTokensTable.id] }) { + it[replacedById] = newTokenId + it[OobeRefreshTokensTable.rotationIdempotencyKey] = rotationIdempotencyKey + it[revokedAt] = now + } + OobeRefreshRotationResult.Rotated( + StoredOobeRefresh( + grant = grant.toOobeGrant(), + tokenId = newTokenId, + familyId = current[OobeRefreshTokensTable.familyId], + expiresAt = expiresAt, + ), + ) + } + + override suspend fun findActiveGrant( + grantId: String, + subjectId: String, + now: Instant, + ): OobeGrant? = databaseFactory.query { + OobeGrantsTable.selectAll() + .where { + (OobeGrantsTable.id eq grantId) and + (OobeGrantsTable.subjectId eq subjectId) and + OobeGrantsTable.revokedAt.isNull() and + (OobeGrantsTable.expiresAt greater now) + } + .singleOrNull() + ?.toOobeGrant() + } + + override suspend fun claim( + request: OobeProviderRequest, + expiresAt: Instant, + now: Instant, + ): OobeRequestClaim? = databaseFactory.query { + val key = claimKey(request.subjectId, request.feature.name) + val reclaimed = OobeClaimsTable.update({ + key and + (OobeClaimsTable.status eq CLAIMED) and + (OobeClaimsTable.expiresAt lessEq now) + }) { + it[requestId] = request.requestId + it[OobeClaimsTable.expiresAt] = expiresAt + it[updatedAt] = now + } == 1 + val inserted = !reclaimed && OobeClaimsTable.insertIgnore { + it[subjectId] = request.subjectId + it[feature] = request.feature.name + it[requestId] = request.requestId + it[status] = CLAIMED + it[OobeClaimsTable.expiresAt] = expiresAt + it[createdAt] = now + it[updatedAt] = now + }.insertedCount == 1 + if (!reclaimed && !inserted) return@query null + + val auditInserted = OobeProviderRequestsTable.insertIgnore { + it[subjectId] = request.subjectId + it[requestId] = request.requestId + it[grantId] = request.grantId + it[feature] = request.feature.name + it[providerId] = request.providerId + it[capability] = request.capability.name + it[requestPurpose] = request.purpose.name + it[status] = OobeProviderRequestState.CLAIMED.name + it[createdAt] = now + }.insertedCount == 1 + if (!auditInserted) throw OobeRequestAlreadyClaimedException() + OobeRequestClaim(request.subjectId, request.feature, request.requestId) + } + + override suspend fun markStarted(claim: OobeRequestClaim) { + transition(claim, OobeProviderRequestState.CLAIMED, OobeProviderRequestState.STARTED) + } + + override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) { + databaseFactory.query { + val now = clock.instant() + val claimChanged = OobeClaimsTable.update({ + claimKey(claim.subjectId, claim.feature.name) and + (OobeClaimsTable.requestId eq claim.requestId) and + (OobeClaimsTable.status eq CLAIMED) + }) { + it[status] = CONSUMED + it[updatedAt] = now + } + check(claimChanged == 1) { "OOBE feature claim cannot be consumed" } + val auditChanged = OobeProviderRequestsTable.update({ + requestKey(claim) and + (OobeProviderRequestsTable.status eq OobeProviderRequestState.STARTED.name) + }) { + it[status] = OobeProviderRequestState.SUCCEEDED.name + it[providerRequestId] = usage.providerRequestId + it[usageMeter] = usage.meter.name + it[usageUnits] = usage.units + it[usageInputUnits] = usage.inputUnits + it[usageOutputUnits] = usage.outputUnits + it[serverDurationMillis] = usage.serverDurationMillis + it[completedAt] = now + } + check(auditChanged == 1) { "OOBE provider request cannot be completed" } + } + } + + override suspend fun release(claim: OobeRequestClaim, errorCode: String) { + databaseFactory.query { + OobeClaimsTable.deleteWhere { + claimKey(claim.subjectId, claim.feature.name) and + (OobeClaimsTable.requestId eq claim.requestId) and + (OobeClaimsTable.status eq CLAIMED) + } + val changed = OobeProviderRequestsTable.update({ + requestKey(claim) and + ( + (OobeProviderRequestsTable.status eq OobeProviderRequestState.CLAIMED.name) or + (OobeProviderRequestsTable.status eq OobeProviderRequestState.STARTED.name) + ) + }) { + it[status] = OobeProviderRequestState.RELEASED.name + it[OobeProviderRequestsTable.errorCode] = errorCode.take(96) + it[completedAt] = clock.instant() + } + check(changed == 1) { "OOBE provider request cannot be released" } + } + } + + override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) { + databaseFactory.query { + // Fail closed: an uncertain provider outcome must never become + // reclaimable after the temporary claim TTL. + OobeClaimsTable.update({ + claimKey(claim.subjectId, claim.feature.name) and + (OobeClaimsTable.requestId eq claim.requestId) and + (OobeClaimsTable.status eq CLAIMED) + }) { + it[status] = CONSUMED + it[updatedAt] = clock.instant() + } + OobeProviderRequestsTable.update({ requestKey(claim) }) { + it[status] = OobeProviderRequestState.MANUAL_REVIEW.name + it[OobeProviderRequestsTable.errorCode] = errorCode.take(96) + } + } + } + + private suspend fun transition( + claim: OobeRequestClaim, + from: OobeProviderRequestState, + to: OobeProviderRequestState, + ) { + databaseFactory.query { + val changed = OobeProviderRequestsTable.update({ + requestKey(claim) and (OobeProviderRequestsTable.status eq from.name) + }) { + it[status] = to.name + } + check(changed == 1) { "OOBE provider request cannot transition from $from to $to" } + } + } +} + +private fun org.jetbrains.exposed.v1.core.ResultRow.toOobeGrant() = OobeGrant( + id = this[OobeGrantsTable.id], + subjectId = this[OobeGrantsTable.subjectId], + expiresAt = this[OobeGrantsTable.expiresAt], + revokedAt = this[OobeGrantsTable.revokedAt], +) + +private fun org.jetbrains.exposed.v1.core.ResultRow.toStoredRefresh(grant: OobeGrant) = + StoredOobeRefresh( + grant = grant, + tokenId = this[OobeRefreshTokensTable.id], + familyId = this[OobeRefreshTokensTable.familyId], + expiresAt = this[OobeRefreshTokensTable.expiresAt], + ) + +private fun claimKey(subjectId: String, feature: String) = + (OobeClaimsTable.subjectId eq subjectId) and (OobeClaimsTable.feature eq feature) + +private fun requestKey(claim: OobeRequestClaim) = + (OobeProviderRequestsTable.subjectId eq claim.subjectId) and + (OobeProviderRequestsTable.requestId eq claim.requestId) + +private const val CLAIMED = "CLAIMED" +private const val CONSUMED = "CONSUMED" diff --git a/src/main/kotlin/com/osglab/account/features/oobe/OobeGrantService.kt b/src/main/kotlin/com/osglab/account/features/oobe/OobeGrantService.kt new file mode 100644 index 0000000..df74929 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/oobe/OobeGrantService.kt @@ -0,0 +1,240 @@ +package com.osglab.account.features.oobe + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.crypto.MACVerifier +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayPrincipal +import com.osglab.account.features.gateway.models.GatewaySubjectType +import com.osglab.account.features.integrity.AppAttestService +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.time.Clock +import java.time.Duration +import java.util.Base64 +import java.util.Date +import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +class OobeGrantService( + private val repository: OobeRepository, + private val appAttest: AppAttestService, + private val settings: OobeTokenSettings, + private val clock: Clock = Clock.systemUTC(), +) { + suspend fun create(request: CreateOobeGrantRequest): OobeGrantTokens { + val installationId = canonicalInstallationId(request.installationId) + val challenge = decodeChallenge(request.challenge) + val canonicalPayload = OobeContract.canonicalAssertionPayload( + challenge = challenge, + keyId = request.keyId, + installationId = installationId, + ) + appAttest.verifyBoundAssertion( + challengeId = request.challengeId, + challenge = challenge, + keyId = request.keyId, + assertionObject = request.assertion, + expectedClientDataHash = sha256(canonicalPayload), + ) + + val now = clock.instant() + val subject = repository.findOrCreateSubject( + keyId = request.keyId, + installationHash = sha256Hex(installationId.toByteArray(StandardCharsets.UTF_8)), + subjectId = UUID.randomUUID().toString(), + now = now, + ) + val grantId = UUID.randomUUID().toString() + val tokenId = UUID.randomUUID().toString() + val familyId = UUID.randomUUID().toString() + val grantExpiresAt = now.plus(GRANT_LIFETIME) + val refreshToken = refreshToken(grantId, familyId, tokenId) + val stored = repository.createGrant( + NewOobeGrant( + grant = OobeGrant(grantId, subject.id, grantExpiresAt), + refreshTokenId = tokenId, + refreshFamilyId = familyId, + refreshTokenHash = tokenHash(refreshToken), + refreshExpiresAt = grantExpiresAt, + ), + now, + ) + return issue(stored) + } + + suspend fun refresh(refreshToken: String, idempotencyKey: String): OobeGrantTokens { + require(IDEMPOTENCY_KEY.matches(idempotencyKey)) { "Idempotency key is invalid" } + if (refreshToken.length !in 32..MAX_REFRESH_TOKEN_CHARS) { + throw OobeRefreshTokenInvalidException() + } + parseRefreshToken(refreshToken) + val now = clock.instant() + val tokenId = UUID.randomUUID().toString() + val result = repository.rotateRefresh( + currentTokenHash = tokenHash(refreshToken), + rotationIdempotencyKey = idempotencyKey, + newTokenId = tokenId, + newTokenHash = tokenHash(replaceTokenId(refreshToken, tokenId)), + newExpiresAt = now.plus(GRANT_LIFETIME), + now = now, + ) + return when (result) { + is OobeRefreshRotationResult.Rotated -> issue(result.refresh) + OobeRefreshRotationResult.Invalid -> throw OobeRefreshTokenInvalidException() + OobeRefreshRotationResult.ReuseDetected -> throw OobeRefreshTokenReuseException() + } + } + + suspend fun authenticate(serialized: String): GatewayPrincipal? { + val principal = verifyAccessToken(serialized) ?: return null + return repository.findActiveGrant( + grantId = requireNotNull(principal.grantId), + subjectId = principal.userId, + now = clock.instant(), + )?.let { + principal + } + } + + private fun issue(refresh: StoredOobeRefresh): OobeGrantTokens { + val now = clock.instant() + val accessExpiresAt = minOf(now.plus(ACCESS_LIFETIME), refresh.grant.expiresAt) + require(accessExpiresAt.isAfter(now)) { "OOBE gateway grant has expired" } + require(refresh.expiresAt.isAfter(now)) { "OOBE refresh token has expired" } + val claims = JWTClaimsSet.Builder() + .issuer(settings.issuer) + .audience(settings.audience) + .subject("$SUBJECT_PREFIX${refresh.grant.subjectId}") + .jwtID(UUID.randomUUID().toString()) + .issueTime(Date.from(now)) + .notBeforeTime(Date.from(now.minusSeconds(CLOCK_SKEW_SECONDS))) + .expirationTime(Date.from(accessExpiresAt)) + .claim(CLAIM_TYPE, ACCESS_TOKEN_TYPE) + .claim(CLAIM_GRANT_ID, refresh.grant.id) + .claim(CLAIM_SCOPES, OobeContract.scopes.map { it.name.lowercase() }.sorted()) + .claim(CLAIM_FEATURES, OobeContract.features.map { it.name.lowercase() }.sorted()) + .build() + val jwt = SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims) + jwt.sign(MACSigner(settings.accessTokenHmacSecret)) + return OobeGrantTokens( + grantId = refresh.grant.id, + scopes = OobeContract.scopes, + features = OobeContract.features, + accessToken = jwt.serialize(), + accessExpiresAt = accessExpiresAt.toString(), + refreshToken = refreshToken(refresh.grant.id, refresh.familyId, refresh.tokenId), + refreshExpiresAt = refresh.expiresAt.toString(), + ) + } + + private fun verifyAccessToken(serialized: String): GatewayPrincipal? = runCatching { + val jwt = SignedJWT.parse(serialized) + require(jwt.header.algorithm == JWSAlgorithm.HS256) + require(jwt.verify(MACVerifier(settings.accessTokenHmacSecret))) + val claims = jwt.jwtClaimsSet + val now = clock.instant() + require(claims.issuer == settings.issuer) + require(settings.audience in claims.audience) + require(claims.getStringClaim(CLAIM_TYPE) == ACCESS_TOKEN_TYPE) + require(claims.expirationTime?.toInstant()?.isAfter(now) == true) + require(claims.notBeforeTime?.toInstant()?.isBefore(now.plusSeconds(CLOCK_SKEW_SECONDS)) != false) + require(claims.issueTime?.toInstant()?.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)) != true) + require(claims.getStringListClaim(CLAIM_SCOPES).map(String::uppercase) + .map(GatewayCapability::valueOf).toSet() == OobeContract.scopes) + require(claims.getStringListClaim(CLAIM_FEATURES).map(String::uppercase).toSet() == + OobeContract.features.map { it.name }.toSet()) + val subject = claims.subject + require(subject.startsWith(SUBJECT_PREFIX)) + val subjectId = UUID.fromString(subject.removePrefix(SUBJECT_PREFIX)).toString() + GatewayPrincipal( + userId = subjectId, + grantId = UUID.fromString(claims.getStringClaim(CLAIM_GRANT_ID)).toString(), + scopes = OobeContract.scopes, + subjectType = GatewaySubjectType.OOBE, + ) + }.getOrNull() + + private fun refreshToken(grantId: String, familyId: String, tokenId: String): String { + val publicPart = "$grantId.$familyId.$tokenId" + val mac = Mac.getInstance(HMAC_ALGORITHM) + mac.init(SecretKeySpec(settings.refreshTokenHmacSecret, HMAC_ALGORITHM)) + val secret = Base64.getUrlEncoder().withoutPadding() + .encodeToString(mac.doFinal("$REFRESH_CONTEXT:$publicPart".toByteArray(StandardCharsets.US_ASCII))) + return "$REFRESH_PREFIX$publicPart.$secret" + } + + private fun parseRefreshToken(value: String) { + if (!value.startsWith(REFRESH_PREFIX)) throw OobeRefreshTokenInvalidException() + val parts = value.removePrefix(REFRESH_PREFIX).split('.') + if (parts.size != 4) throw OobeRefreshTokenInvalidException() + val grantId = canonicalUuid(parts[0]) + val familyId = canonicalUuid(parts[1]) + val tokenId = canonicalUuid(parts[2]) + val expected = refreshToken(grantId, familyId, tokenId) + if (!MessageDigest.isEqual( + expected.toByteArray(StandardCharsets.US_ASCII), + value.toByteArray(StandardCharsets.US_ASCII), + ) + ) { + throw OobeRefreshTokenInvalidException() + } + } + + private fun replaceTokenId(value: String, newTokenId: String): String { + val parts = value.removePrefix(REFRESH_PREFIX).split('.') + return refreshToken(parts[0], parts[1], newTokenId) + } + + private fun canonicalInstallationId(value: String): String = + runCatching { UUID.fromString(value).toString() } + .getOrElse { throw IllegalArgumentException("installationId must be a UUID") } + + private fun canonicalUuid(value: String): String = + runCatching { UUID.fromString(value).toString() } + .getOrElse { throw OobeRefreshTokenInvalidException() } + + private fun decodeChallenge(value: String): ByteArray = + runCatching { Base64.getUrlDecoder().decode(value) } + .getOrElse { throw IllegalArgumentException("challenge must be Base64URL") } + .also { require(it.size == CHALLENGE_BYTES) { "challenge size is invalid" } } + + private fun tokenHash(value: String): String = sha256Hex(value.toByteArray(StandardCharsets.US_ASCII)) + + private companion object { + val GRANT_LIFETIME: Duration = Duration.ofMinutes(30) + val ACCESS_LIFETIME: Duration = Duration.ofMinutes(5) + const val HMAC_ALGORITHM = "HmacSHA256" + const val REFRESH_CONTEXT = "oobe-refresh" + const val CLAIM_TYPE = "typ" + const val CLAIM_GRANT_ID = "gid" + const val CLAIM_SCOPES = "scp" + const val CLAIM_FEATURES = "features" + const val ACCESS_TOKEN_TYPE = "oobe_gateway_access" + const val SUBJECT_PREFIX = "oobe:" + const val REFRESH_PREFIX = "oobert_" + const val CLOCK_SKEW_SECONDS = 30L + const val CHALLENGE_BYTES = 32 + const val MAX_REFRESH_TOKEN_CHARS = 512 + val IDEMPOTENCY_KEY = Regex("[A-Za-z0-9._:-]{8,128}") + } +} + +class OobeRefreshTokenInvalidException : RuntimeException("OOBE refresh token is invalid") +class OobeRefreshTokenReuseException : RuntimeException("OOBE refresh token reuse was detected") + +data class OobeTokenSettings( + val issuer: String, + val audience: String, + val accessTokenHmacSecret: ByteArray, + val refreshTokenHmacSecret: ByteArray, +) + +private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value) + +private fun sha256Hex(value: ByteArray): String = + sha256(value).joinToString("") { "%02x".format(it.toInt() and 0xff) } diff --git a/src/main/kotlin/com/osglab/account/features/oobe/OobeModels.kt b/src/main/kotlin/com/osglab/account/features/oobe/OobeModels.kt new file mode 100644 index 0000000..31700e8 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/oobe/OobeModels.kt @@ -0,0 +1,128 @@ +package com.osglab.account.features.oobe + +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayRequestPurpose +import com.osglab.account.features.gateway.models.GatewayTaskKind +import com.osglab.account.features.gateway.models.OobeFeature +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.Base64 + +@Serializable +data class CreateOobeGrantRequest( + val challengeId: String, + val challenge: String, + val keyId: String, + val installationId: String, + val assertion: String, +) + +@Serializable +data class RefreshOobeGrantRequest(val refreshToken: String) + +@Serializable +data class OobeGrantTokens( + val grantId: String, + val scopes: Set, + val features: Set, + val accessToken: String, + val accessExpiresAt: String, + val refreshToken: String, + val refreshExpiresAt: String, +) + +data class OobeFeaturePolicy( + val capability: GatewayCapability, + val taskKind: GatewayTaskKind, +) + +object OobeContract { + val scopes: Set = setOf(GatewayCapability.POLISH, GatewayCapability.AI) + val features: Set = OobeFeature.entries.toSet() + + fun policy(feature: OobeFeature): OobeFeaturePolicy = when (feature) { + OobeFeature.VOICE_INPUT -> + OobeFeaturePolicy(GatewayCapability.POLISH, GatewayTaskKind.DICTATION_POLISH) + OobeFeature.CLIPBOARD_TRANSLATE, + OobeFeature.CLIPBOARD_REPLY -> + OobeFeaturePolicy(GatewayCapability.AI, GatewayTaskKind.CLIPBOARD_TRANSFORM) + OobeFeature.ASK_AI -> + OobeFeaturePolicy(GatewayCapability.AI, GatewayTaskKind.AI_QUESTION) + } + + fun canonicalAssertionPayload( + challenge: ByteArray, + keyId: String, + installationId: String, + ): ByteArray = buildString { + appendLine("osg-app-attest-v1") + appendLine("purpose=oobe-gateway-grant") + appendLine("challenge=${BASE64_URL.encodeToString(challenge)}") + appendLine("key_id=$keyId") + appendLine("installation_id=$installationId") + appendLine("scopes=ai,polish") + appendLine("features=ask_ai,clipboard_reply,clipboard_translate,voice_input") + appendLine("grant_ttl_seconds=1800") + appendLine("access_ttl_seconds=300") + }.toByteArray(Charsets.UTF_8) +} + +data class OobeSubject( + val id: String, + val keyId: String, + val installationHash: String, +) + +data class OobeGrant( + val id: String, + val subjectId: String, + val expiresAt: Instant, + val revokedAt: Instant? = null, +) + +data class NewOobeGrant( + val grant: OobeGrant, + val refreshTokenId: String, + val refreshFamilyId: String, + val refreshTokenHash: String, + val refreshExpiresAt: Instant, +) + +data class StoredOobeRefresh( + val grant: OobeGrant, + val tokenId: String, + val familyId: String, + val expiresAt: Instant, +) + +sealed interface OobeRefreshRotationResult { + data class Rotated(val refresh: StoredOobeRefresh) : OobeRefreshRotationResult + data object Invalid : OobeRefreshRotationResult + data object ReuseDetected : OobeRefreshRotationResult +} + +data class OobeRequestClaim( + val subjectId: String, + val feature: OobeFeature, + val requestId: String, +) + +data class OobeProviderRequest( + val subjectId: String, + val grantId: String, + val feature: OobeFeature, + val requestId: String, + val providerId: String, + val capability: GatewayCapability, + val purpose: GatewayRequestPurpose, +) + +enum class OobeProviderRequestState { + CLAIMED, + STARTED, + SUCCEEDED, + RELEASED, + MANUAL_REVIEW, +} + +private val BASE64_URL: Base64.Encoder = Base64.getUrlEncoder().withoutPadding() diff --git a/src/main/kotlin/com/osglab/account/features/oobe/OobeRepository.kt b/src/main/kotlin/com/osglab/account/features/oobe/OobeRepository.kt new file mode 100644 index 0000000..7b4d0e9 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/oobe/OobeRepository.kt @@ -0,0 +1,42 @@ +package com.osglab.account.features.oobe + +import com.osglab.account.features.gateway.models.ProviderUsage +import java.time.Instant + +interface OobeRepository { + suspend fun findOrCreateSubject( + keyId: String, + installationHash: String, + subjectId: String, + now: Instant, + ): OobeSubject + + suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh + + suspend fun rotateRefresh( + currentTokenHash: String, + rotationIdempotencyKey: String, + newTokenId: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): OobeRefreshRotationResult + + suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? + + suspend fun claim(request: OobeProviderRequest, expiresAt: Instant, now: Instant): OobeRequestClaim? + + suspend fun markStarted(claim: OobeRequestClaim) + + suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) + + suspend fun release(claim: OobeRequestClaim, errorCode: String) + + suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) +} + +class OobeRequestAlreadyClaimedException : + RuntimeException("The OOBE provider request ID has already been used") + +class OobeFeatureAlreadyUsedException(val feature: com.osglab.account.features.gateway.models.OobeFeature) : + RuntimeException("The OOBE feature ${feature.name.lowercase()} has already been used") diff --git a/src/main/kotlin/com/osglab/account/features/oobe/OobeRoutes.kt b/src/main/kotlin/com/osglab/account/features/oobe/OobeRoutes.kt new file mode 100644 index 0000000..a2d629b --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/oobe/OobeRoutes.kt @@ -0,0 +1,130 @@ +package com.osglab.account.features.oobe + +import com.osglab.account.common.errors.InvalidRequestException +import com.osglab.account.features.gateway.models.GatewayErrorResponse +import com.osglab.account.features.integrity.AppAttestRejectedException +import com.osglab.account.features.integrity.AppAttestUnavailableException +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receiveChannel +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import java.util.UUID +import kotlinx.serialization.json.Json +import kotlinx.io.readByteArray +import io.ktor.utils.io.readRemaining + +fun Route.oobeRoutes(service: OobeGrantService) { + route("/v1/oobe/grants") { + post { + val requestId = call.requestId() + val request = runCatching { + OOBE_JSON.decodeFromString(call.receiveOobeBody()) + }.getOrElse { + return@post call.respond( + HttpStatusCode.BadRequest, + GatewayErrorResponse("invalid_oobe_grant", "OOBE grant request is invalid", requestId), + ) + } + try { + call.respond(HttpStatusCode.Created, service.create(request)) + } catch (_: AppAttestRejectedException) { + call.respond( + HttpStatusCode.Unauthorized, + GatewayErrorResponse("app_attest_rejected", "App Attest assertion was rejected", requestId), + ) + } catch (_: AppAttestUnavailableException) { + call.respond( + HttpStatusCode.ServiceUnavailable, + GatewayErrorResponse( + "app_attest_unavailable", + "App Attest verification is unavailable", + requestId, + ), + ) + } catch (_: InvalidRequestException) { + call.respond( + HttpStatusCode.BadRequest, + GatewayErrorResponse( + "invalid_oobe_grant", + "OOBE grant request is invalid", + requestId, + ), + ) + } catch (failure: IllegalArgumentException) { + call.respond( + HttpStatusCode.BadRequest, + GatewayErrorResponse( + "invalid_oobe_grant", + failure.message ?: "OOBE grant request is invalid", + requestId, + ), + ) + } + } + + post("/refresh") { + val requestId = call.requestId() + val idempotencyKey = call.request.headers["Idempotency-Key"] + ?: return@post call.respond( + HttpStatusCode.BadRequest, + GatewayErrorResponse( + "missing_idempotency_key", + "Idempotency-Key is required", + requestId, + ), + ) + val request = runCatching { + OOBE_JSON.decodeFromString(call.receiveOobeBody()) + }.getOrElse { + return@post call.respond( + HttpStatusCode.BadRequest, + GatewayErrorResponse("invalid_oobe_refresh", "OOBE refresh request is invalid", requestId), + ) + } + try { + call.respond(service.refresh(request.refreshToken, idempotencyKey)) + } catch (_: OobeRefreshTokenInvalidException) { + call.respond( + HttpStatusCode.Unauthorized, + GatewayErrorResponse("invalid_oobe_refresh", "OOBE refresh token is invalid", requestId), + ) + } catch (_: OobeRefreshTokenReuseException) { + call.respond( + HttpStatusCode.Unauthorized, + GatewayErrorResponse("oobe_refresh_reuse", "OOBE refresh token reuse was detected", requestId), + ) + } catch (failure: IllegalArgumentException) { + call.respond( + HttpStatusCode.BadRequest, + GatewayErrorResponse( + "invalid_oobe_refresh", + failure.message ?: "OOBE refresh request is invalid", + requestId, + ), + ) + } + } + } +} + +private fun io.ktor.server.application.ApplicationCall.requestId(): String = + request.headers["X-Request-ID"]?.takeIf { REQUEST_ID.matches(it) } ?: UUID.randomUUID().toString() + +private val REQUEST_ID = Regex("[A-Za-z0-9_-]{8,64}") +private const val MAX_OOBE_BODY_BYTES = 128 * 1024 +private val OOBE_JSON = Json { + ignoreUnknownKeys = false + explicitNulls = false +} + +private suspend fun io.ktor.server.application.ApplicationCall.receiveOobeBody(): String { + val declared = request.headers["Content-Length"]?.toLongOrNull() + require(declared == null || declared <= MAX_OOBE_BODY_BYTES) + val bytes = receiveChannel() + .readRemaining(MAX_OOBE_BODY_BYTES.toLong() + 1) + .readByteArray() + require(bytes.size <= MAX_OOBE_BODY_BYTES) + return bytes.decodeToString() +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3ba6fd0..fe4714f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -78,4 +78,5 @@ app: enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false" enforceAppAttest: "$ENFORCE_APP_ATTEST:false" appleEnvironment: "$APPLE_INTEGRITY_ENVIRONMENT:development" + allowDevelopmentAppAttest: "$ALLOW_DEVELOPMENT_APP_ATTEST:false" challengeLifetimeSeconds: "$APP_ATTEST_CHALLENGE_TTL_SECONDS:300" diff --git a/src/main/resources/db/migration/V26__anonymous_oobe_gateway.sql b/src/main/resources/db/migration/V26__anonymous_oobe_gateway.sql new file mode 100644 index 0000000..641cf5b --- /dev/null +++ b/src/main/resources/db/migration/V26__anonymous_oobe_gateway.sql @@ -0,0 +1,87 @@ +CREATE TABLE oobe_subjects ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + key_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + installation_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_oobe_subject_key (key_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE oobe_gateway_grants ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + subject_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + expires_at TIMESTAMP(6) NOT NULL, + revoked_at TIMESTAMP(6) NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id), + INDEX idx_oobe_grants_subject_expiry (subject_id, expires_at), + CONSTRAINT fk_oobe_grants_subject + FOREIGN KEY (subject_id) REFERENCES oobe_subjects (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE oobe_gateway_refresh_tokens ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + family_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + replaced_by_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + rotation_idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL, + expires_at TIMESTAMP(6) NOT NULL, + revoked_at TIMESTAMP(6) NULL, + reuse_detected_at TIMESTAMP(6) NULL, + created_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_oobe_refresh_hash (token_hash), + INDEX idx_oobe_refresh_grant (grant_id), + INDEX idx_oobe_refresh_family (family_id), + CONSTRAINT fk_oobe_refresh_grant + FOREIGN KEY (grant_id) REFERENCES oobe_gateway_grants (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE oobe_gateway_claims ( + subject_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + feature VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + expires_at TIMESTAMP(6) NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (subject_id, feature), + INDEX idx_oobe_claim_expiry (status, expires_at), + CONSTRAINT fk_oobe_claim_subject + FOREIGN KEY (subject_id) REFERENCES oobe_subjects (id) ON DELETE CASCADE, + CONSTRAINT chk_oobe_claim_feature + CHECK (feature IN ('VOICE_INPUT', 'CLIPBOARD_TRANSLATE', 'CLIPBOARD_REPLY', 'ASK_AI')), + CONSTRAINT chk_oobe_claim_status + CHECK (status IN ('CLAIMED', 'CONSUMED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE oobe_provider_requests ( + subject_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + feature VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + provider_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + capability VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + request_purpose VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + provider_request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL, + usage_meter VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL, + usage_units BIGINT NULL, + usage_input_units BIGINT NULL, + usage_output_units BIGINT NULL, + server_duration_millis BIGINT NULL, + error_code VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NULL, + created_at TIMESTAMP(6) NOT NULL, + completed_at TIMESTAMP(6) NULL, + PRIMARY KEY (subject_id, request_id), + INDEX idx_oobe_provider_feature_created (feature, created_at), + INDEX idx_oobe_provider_status_created (status, created_at), + CONSTRAINT fk_oobe_provider_subject + FOREIGN KEY (subject_id) REFERENCES oobe_subjects (id) ON DELETE CASCADE, + CONSTRAINT fk_oobe_provider_grant + FOREIGN KEY (grant_id) REFERENCES oobe_gateway_grants (id) ON DELETE CASCADE, + CONSTRAINT chk_oobe_provider_purpose CHECK (request_purpose = 'OOBE') +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt b/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt index 004875d..2585c79 100644 --- a/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt +++ b/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt @@ -36,6 +36,15 @@ class AppConfigTest : FunSpec({ config.environment shouldBe Environment.PRODUCTION config.database.username shouldBe "test" config.database.migrationUsername shouldBe "test_migrator" + config.integrity.allowDevelopmentAppAttest shouldBe false + } + + test("production can explicitly allow development App Attest builds") { + val config = validProductionConfig().apply { + put("app.integrity.allowDevelopmentAppAttest", "true") + } + + AppConfig.from(config).integrity.allowDevelopmentAppAttest shouldBe true } test("production accepts enabled admin bootstrap with Argon2 PHC hash") { diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt index fa1570f..90f105f 100644 --- a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -378,6 +378,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf( "/v1/integrity/challenges", "/v1/integrity/attest", "/v1/integrity/assert", + "/v1/oobe/grants", + "/v1/oobe/grants/refresh", "/v1/gateway/catalog", "/v1/gateway/grants", "/v1/gateway/grants/refresh", diff --git a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt index 60147ff..b457da8 100644 --- a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt +++ b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt @@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({ runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/" runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/" runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/" - runner shouldContain "Flyway history was not exactly successful V1-V17" + runner shouldContain "Flyway history was not exactly successful V1-V26" runner shouldContain "default referral rewards were not 1000 credits for both accounts" runner shouldContain "active smaller credit rates did not match the V10 contract" runner shouldContain "first ledger page omitted nextCursor" diff --git a/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt b/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt index 1f6c9a8..aa2d0d9 100644 --- a/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt +++ b/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt @@ -72,6 +72,23 @@ class AppAttestCryptoTest : FunSpec({ } } + test("production can explicitly allow development App Attest builds") { + val fixture = AppAttestFixture() + val developmentAaguid = "appattestdevelop".toByteArray(Charsets.US_ASCII) + val crypto = fixture.crypto( + nonce = fixture.expectedNonce(developmentAaguid), + allowDevelopment = true, + ) + + val material = crypto.validateAttestation( + fixture.attestationObject(aaguid = developmentAaguid), + fixture.keyId, + fixture.challenge, + ) + + material.publicKey shouldBe fixture.keyPair.public.encoded + } + test("assertion verifies ECDSA and requires a strictly increasing counter") { val fixture = AppAttestFixture() val hash = sha256ForTest("cost-request".toByteArray()) @@ -139,12 +156,16 @@ private class AppAttestFixture { sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)), ) - fun crypto(nonce: ByteArray = expectedNonce()): LibraryAppAttestCrypto = + fun crypto( + nonce: ByteArray = expectedNonce(), + allowDevelopment: Boolean = false, + ): LibraryAppAttestCrypto = LibraryAppAttestCrypto( IntegrityConfig( deviceCheckPolicy = IntegrityPolicy.ENFORCE, appAttestPolicy = IntegrityPolicy.ENFORCE, appleEnvironment = AppleServiceEnvironment.PRODUCTION, + allowDevelopmentAppAttest = allowDevelopment, ), AppAttestCertificateValidator { ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce) @@ -189,8 +210,8 @@ private class AppAttestFixture { .EncodeToBytes() } - private fun expectedNonce(): ByteArray = - sha256ForTest(attestationAuthData(rpIdHash, productionAaguid()) + sha256ForTest(challenge)) + fun expectedNonce(aaguid: ByteArray = productionAaguid()): ByteArray = + sha256ForTest(attestationAuthData(rpIdHash, aaguid) + sha256ForTest(challenge)) private fun productionAaguid(): ByteArray = "appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7) diff --git a/src/test/kotlin/com/osglab/account/features/oobe/OobeGatewayServiceTest.kt b/src/test/kotlin/com/osglab/account/features/oobe/OobeGatewayServiceTest.kt new file mode 100644 index 0000000..9e8b5de --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/oobe/OobeGatewayServiceTest.kt @@ -0,0 +1,264 @@ +package com.osglab.account.features.oobe + +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayModelProfile +import com.osglab.account.features.gateway.models.GatewayPrincipal +import com.osglab.account.features.gateway.models.GatewayReasoningEffort +import com.osglab.account.features.gateway.models.GatewayRequestPurpose +import com.osglab.account.features.gateway.models.GatewaySubjectType +import com.osglab.account.features.gateway.models.GatewayTaskExecutionPolicy +import com.osglab.account.features.gateway.models.GatewayTaskKind +import com.osglab.account.features.gateway.models.GatewayThinkingMode +import com.osglab.account.features.gateway.models.GatewayToolsMode +import com.osglab.account.features.gateway.models.GatewayWebSearchMode +import com.osglab.account.features.gateway.models.OobeFeature +import com.osglab.account.features.gateway.models.ProviderDescriptor +import com.osglab.account.features.gateway.models.ProviderOutput +import com.osglab.account.features.gateway.models.ProviderRequest +import com.osglab.account.features.gateway.models.ProviderUsage +import com.osglab.account.features.gateway.models.TextProviderRequest +import com.osglab.account.features.gateway.models.UsageMeter +import com.osglab.account.features.gateway.ports.CreditReservation +import com.osglab.account.features.gateway.ports.CreditReservationPort +import com.osglab.account.features.gateway.ports.GatewayGrantPort +import com.osglab.account.features.gateway.ports.GatewayUsagePort +import com.osglab.account.features.gateway.ports.PendingSettlement +import com.osglab.account.features.gateway.ports.ProviderRequestMetadata +import com.osglab.account.features.gateway.providers.GatewayProvider +import com.osglab.account.features.gateway.providers.ProviderCatalog +import com.osglab.account.features.gateway.services.GatewayAccessDeniedException +import com.osglab.account.features.gateway.services.GatewayService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.Instant + +class OobeGatewayServiceTest : StringSpec({ + "executes each fixed OOBE feature once without touching credits or account audit" { + val credits = CountingCredits() + val oobe = FakeOobeExecutionRepository() + val service = service(credits, oobe) + + OobeFeature.entries.forEachIndexed { index, feature -> + service.execute(OOBE_PRINCIPAL, request(feature, "oobe-feature-$index"), DISCARD) + } + + credits.calls shouldBe 0 + oobe.consumed.map(OobeRequestClaim::feature).toSet() shouldBe OobeFeature.entries.toSet() + } + + "rejects a fifth call and a repeated feature without paid fallback" { + val credits = CountingCredits() + val oobe = FakeOobeExecutionRepository() + val service = service(credits, oobe) + OobeFeature.entries.forEachIndexed { index, feature -> + service.execute(OOBE_PRINCIPAL, request(feature, "oobe-once-$index"), DISCARD) + } + + shouldThrow { + service.execute(OOBE_PRINCIPAL, request(OobeFeature.ASK_AI, "oobe-fifth-call"), DISCARD) + } + credits.calls shouldBe 0 + } + + "releases the feature claim when the provider fails" { + val credits = CountingCredits() + val oobe = FakeOobeExecutionRepository() + val service = service(credits, oobe, fail = true) + + shouldThrow { + service.execute(OOBE_PRINCIPAL, request(OobeFeature.VOICE_INPUT, "oobe-provider-fail"), DISCARD) + } + + oobe.released.map(OobeRequestClaim::feature) shouldBe listOf(OobeFeature.VOICE_INPUT) + credits.calls shouldBe 0 + } + + "enforces token boundary and exact feature mapping" { + val credits = CountingCredits() + val service = service(credits, FakeOobeExecutionRepository()) + + shouldThrow { + service.execute( + OOBE_PRINCIPAL, + request(OobeFeature.ASK_AI, "oobe-wrong-map").copy( + executionPolicy = policy(GatewayTaskKind.CLIPBOARD_TRANSFORM), + ), + DISCARD, + ) + } + shouldThrow { + service.execute( + ACCOUNT_PRINCIPAL, + request(OobeFeature.ASK_AI, "account-oobe-feature"), + DISCARD, + ) + } + credits.calls shouldBe 0 + } +}) + +private fun service( + credits: CountingCredits, + oobe: OobeRepository, + fail: Boolean = false, +): GatewayService = GatewayService( + catalog = ProviderCatalog(listOf(FakeOobeProvider(fail))), + credits = credits, + grants = GatewayGrantPort { _, _ -> error("account grant lookup must not run for OOBE") }, + usageRecords = NoAccountUsage, + oobeRequests = oobe, +) + +private class FakeOobeProvider(private val fail: Boolean) : GatewayProvider { + override val descriptor = ProviderDescriptor( + id = "oobe-test-provider", + capabilities = setOf(GatewayCapability.POLISH, GatewayCapability.AI), + streaming = false, + usageMeter = UsageMeter.LLM_TOKEN, + ) + + override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage { + if (fail) throw ProviderFailure() + return ProviderUsage( + meter = UsageMeter.LLM_TOKEN, + units = 2, + inputUnits = 1, + outputUnits = 1, + ) + } +} + +private class CountingCredits : CreditReservationPort { + var calls = 0 + + override suspend fun reserve( + accountId: String, + meter: UsageMeter, + estimatedUnits: Long, + requestId: String, + ): CreditReservation { + calls += 1 + error("credits must not be called") + } + + override suspend fun settle(reservationId: String, actualUnits: Long) { + calls += 1 + error("credits must not be called") + } + + override suspend fun release(reservationId: String) { + calls += 1 + error("credits must not be called") + } +} + +private class FakeOobeExecutionRepository : OobeRepository { + private val claimedFeatures = mutableSetOf() + val consumed = mutableListOf() + val released = mutableListOf() + + override suspend fun claim( + request: OobeProviderRequest, + expiresAt: Instant, + now: Instant, + ): OobeRequestClaim? { + if (!claimedFeatures.add(request.feature)) return null + return OobeRequestClaim(request.subjectId, request.feature, request.requestId) + } + + override suspend fun markStarted(claim: OobeRequestClaim) = Unit + + override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) { + consumed += claim + } + + override suspend fun release(claim: OobeRequestClaim, errorCode: String) { + claimedFeatures -= claim.feature + released += claim + } + + override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = Unit + + override suspend fun findOrCreateSubject( + keyId: String, + installationHash: String, + subjectId: String, + now: Instant, + ): OobeSubject = error("not used") + + override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh = error("not used") + + override suspend fun rotateRefresh( + currentTokenHash: String, + rotationIdempotencyKey: String, + newTokenId: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): OobeRefreshRotationResult = error("not used") + + override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? = + error("not used") +} + +private object NoAccountUsage : GatewayUsagePort { + override suspend fun claim(metadata: ProviderRequestMetadata) = error("account audit must not be called") + override suspend fun markStarted(accountId: String, requestId: String) = error("account audit must not be called") + override suspend fun markSettlementPending( + accountId: String, + requestId: String, + usage: ProviderUsage, + ) = error("account audit must not be called") + + override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) = + error("account audit must not be called") + + override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = + error("account audit must not be called") + + override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) = + error("account audit must not be called") + + override suspend fun findSettlementPending(limit: Int): List = emptyList() +} + +private fun request(feature: OobeFeature, requestId: String): TextProviderRequest { + val mapping = OobeContract.policy(feature) + return TextProviderRequest( + requestId = requestId, + capability = mapping.capability, + executionPolicy = policy(mapping.taskKind), + input = "hello", + context = null, + maxOutputTokens = 1, + temperature = 0.0, + stream = false, + requestPurpose = GatewayRequestPurpose.OOBE, + oobeFeature = feature, + ) +} + +private fun policy(taskKind: GatewayTaskKind) = GatewayTaskExecutionPolicy( + taskKind = taskKind, + modelProfile = GatewayModelProfile.LOW_LATENCY, + thinking = GatewayThinkingMode.DISABLED, + reasoningEffort = null as GatewayReasoningEffort?, + webSearch = GatewayWebSearchMode.DISABLED, + tools = GatewayToolsMode.DISABLED, + allowEmptyContentRetry = false, + maxOutputTokens = 1, +) + +private val OOBE_PRINCIPAL = GatewayPrincipal( + userId = "20000000-0000-0000-0000-000000000001", + grantId = "30000000-0000-0000-0000-000000000001", + scopes = OobeContract.scopes, + subjectType = GatewaySubjectType.OOBE, +) +private val ACCOUNT_PRINCIPAL = GatewayPrincipal( + userId = "40000000-0000-0000-0000-000000000001", + scopes = OobeContract.scopes, +) +private val DISCARD = ProviderOutput {} +private class ProviderFailure : RuntimeException() diff --git a/src/test/kotlin/com/osglab/account/features/oobe/OobeGrantServiceTest.kt b/src/test/kotlin/com/osglab/account/features/oobe/OobeGrantServiceTest.kt new file mode 100644 index 0000000..23d5b33 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/oobe/OobeGrantServiceTest.kt @@ -0,0 +1,299 @@ +package com.osglab.account.features.oobe + +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.MACSigner +import com.osglab.account.config.AppleServiceEnvironment +import com.osglab.account.config.IntegrityConfig +import com.osglab.account.config.IntegrityPolicy +import com.osglab.account.features.gateway.models.GatewaySubjectType +import com.osglab.account.features.gateway.models.ProviderUsage +import com.osglab.account.features.integrity.AppAttestChallenge +import com.osglab.account.features.integrity.AppAttestChallengePurpose +import com.osglab.account.features.integrity.AppAttestCrypto +import com.osglab.account.features.integrity.AppAttestKeyStatus +import com.osglab.account.features.integrity.AppAttestRepository +import com.osglab.account.features.integrity.AppAttestService +import com.osglab.account.features.integrity.AppAttestRejectedException +import com.osglab.account.features.integrity.AttestedKeyMaterial +import com.osglab.account.features.integrity.ConsumedChallenge +import com.osglab.account.features.integrity.StoredAppAttestKey +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import java.security.MessageDigest +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.util.Base64 +import java.util.UUID + +class OobeGrantServiceTest : StringSpec({ + "canonical assertion is server-owned and binds all fixed permissions" { + val challenge = ByteArray(32) { it.toByte() } + val payload = OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID).decodeToString() + + payload shouldBe """ + osg-app-attest-v1 + purpose=oobe-gateway-grant + challenge=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8 + key_id=$KEY_ID + installation_id=$INSTALLATION_ID + scopes=ai,polish + features=ask_ai,clipboard_reply,clipboard_translate,voice_input + grant_ttl_seconds=1800 + access_ttl_seconds=300 + + """.trimIndent() + } + + "issues a distinct short-lived OOBE token after the canonical assertion" { + val clock = MutableClock(NOW) + val challenge = ByteArray(32) { 7 } + val expectedHash = sha256( + OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID), + ) + val repository = FakeOobeRepository() + val service = service(repository, expectedHash, clock) + + val tokens = service.create(request(challenge)) + val jwt = SignedJWT.parse(tokens.accessToken).jwtClaimsSet + + jwt.getStringClaim("typ") shouldBe "oobe_gateway_access" + jwt.subject.startsWith("oobe:") shouldBe true + jwt.getStringListClaim("scp").shouldContainExactlyInAnyOrder("polish", "ai") + jwt.getStringListClaim("features").shouldContainExactlyInAnyOrder( + "voice_input", + "clipboard_translate", + "clipboard_reply", + "ask_ai", + ) + Duration.between(jwt.issueTime.toInstant(), jwt.expirationTime.toInstant()) shouldBe + Duration.ofMinutes(5) + Duration.between(NOW, Instant.parse(tokens.refreshExpiresAt)) shouldBe Duration.ofMinutes(30) + service.authenticate(tokens.accessToken)?.subjectType shouldBe GatewaySubjectType.OOBE + + val overScoped = SignedJWT( + JWSHeader(JWSAlgorithm.HS256), + JWTClaimsSet.Builder(jwt) + .claim("scp", listOf("ai", "agent", "polish")) + .build(), + ).apply { sign(MACSigner(ByteArray(32) { 1 })) }.serialize() + service.authenticate(overScoped) shouldBe null + + val accountTyped = SignedJWT( + JWSHeader(JWSAlgorithm.HS256), + JWTClaimsSet.Builder(jwt) + .claim("typ", "gateway_access") + .build(), + ).apply { sign(MACSigner(ByteArray(32) { 1 })) }.serialize() + service.authenticate(accountTyped) shouldBe null + + clock.now = NOW.plus(Duration.ofMinutes(5)) + service.authenticate(tokens.accessToken) shouldBe null + } + + "rejects an assertion generated for a different installation payload" { + val challenge = ByteArray(32) { 9 } + val signedHash = sha256( + OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID), + ) + val service = service(FakeOobeRepository(), signedHash, MutableClock(NOW)) + + shouldThrow { + service.create(request(challenge).copy(installationId = UUID.randomUUID().toString())) + } + } + + "refresh cannot extend the original grant TTL" { + val clock = MutableClock(NOW) + val challenge = ByteArray(32) { 5 } + val expectedHash = sha256( + OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID), + ) + val service = service(FakeOobeRepository(), expectedHash, clock) + val created = service.create(request(challenge)) + + clock.now = NOW.plus(Duration.ofMinutes(29)) + val refreshed = service.refresh(created.refreshToken, "oobe-refresh-1") + + refreshed.refreshExpiresAt shouldBe created.refreshExpiresAt + refreshed.accessExpiresAt shouldBe NOW.plus(Duration.ofMinutes(30)).toString() + } +}) + +private fun service( + repository: OobeRepository, + expectedHash: ByteArray, + clock: Clock, +): OobeGrantService { + val appAttestRepository = FakeAppAttestRepository() + val appAttest = AppAttestService( + repository = appAttestRepository, + crypto = HashCheckingAppAttestCrypto(expectedHash), + config = IntegrityConfig( + deviceCheckPolicy = IntegrityPolicy.ENFORCE, + appAttestPolicy = IntegrityPolicy.ENFORCE, + appleEnvironment = AppleServiceEnvironment.PRODUCTION, + ), + clock = clock, + ) + return OobeGrantService( + repository = repository, + appAttest = appAttest, + settings = OobeTokenSettings( + issuer = "osg-test", + audience = "osg-gateway-test", + accessTokenHmacSecret = ByteArray(32) { 1 }, + refreshTokenHmacSecret = ByteArray(32) { 2 }, + ), + clock = clock, + ) +} + +private class HashCheckingAppAttestCrypto( + private val expectedHash: ByteArray, +) : AppAttestCrypto { + override suspend fun validateAttestation( + attestationObject: ByteArray, + keyId: String, + challenge: ByteArray, + ): AttestedKeyMaterial = error("not used") + + override suspend fun validateAssertion( + assertionObject: ByteArray, + clientDataHash: ByteArray, + publicKey: ByteArray, + lastCounter: Long, + ): Long { + if (!MessageDigest.isEqual(clientDataHash, expectedHash)) { + throw AppAttestRejectedException("canonical payload mismatch") + } + return lastCounter + 1 + } +} + +private class FakeAppAttestRepository : AppAttestRepository { + private var counter = 0L + + override suspend fun createChallenge(challenge: AppAttestChallenge) = Unit + + override suspend fun consumeChallenge( + id: UUID, + purpose: AppAttestChallengePurpose, + keyId: String, + challengeHash: String, + accountId: UUID?, + now: Instant, + ): ConsumedChallenge = ConsumedChallenge.Valid + + override suspend fun saveKey(key: StoredAppAttestKey): Boolean = true + + override suspend fun findKey(keyId: String): StoredAppAttestKey = + StoredAppAttestKey( + keyId = keyId, + publicKey = byteArrayOf(1), + receipt = byteArrayOf(1), + counter = counter, + accountId = null, + status = AppAttestKeyStatus.ACTIVE, + ) + + override suspend fun updateCounter( + keyId: String, + expectedCounter: Long, + newCounter: Long, + now: Instant, + ): Boolean { + if (expectedCounter != counter || newCounter <= counter) return false + counter = newCounter + return true + } + + override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean = true +} + +private class FakeOobeRepository : OobeRepository { + private val subjects = mutableMapOf, OobeSubject>() + private val grants = mutableMapOf() + private val refreshes = mutableMapOf() + + override suspend fun findOrCreateSubject( + keyId: String, + installationHash: String, + subjectId: String, + now: Instant, + ): OobeSubject = subjects.getOrPut(keyId to installationHash) { + OobeSubject(subjectId, keyId, installationHash) + } + + override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh { + grants[grant.grant.id] = grant.grant + return StoredOobeRefresh( + grant.grant, + grant.refreshTokenId, + grant.refreshFamilyId, + grant.refreshExpiresAt, + ).also { refreshes[grant.refreshTokenHash] = it } + } + + override suspend fun rotateRefresh( + currentTokenHash: String, + rotationIdempotencyKey: String, + newTokenId: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): OobeRefreshRotationResult { + val current = refreshes[currentTokenHash] ?: return OobeRefreshRotationResult.Invalid + if (!current.expiresAt.isAfter(now) || !current.grant.expiresAt.isAfter(now)) { + return OobeRefreshRotationResult.Invalid + } + val replacement = StoredOobeRefresh( + grant = current.grant, + tokenId = newTokenId, + familyId = current.familyId, + expiresAt = minOf(newExpiresAt, current.grant.expiresAt), + ) + refreshes[newTokenHash] = replacement + return OobeRefreshRotationResult.Rotated(replacement) + } + + override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? = + grants[grantId]?.takeIf { it.subjectId == subjectId && it.expiresAt.isAfter(now) } + + override suspend fun claim( + request: OobeProviderRequest, + expiresAt: Instant, + now: Instant, + ): OobeRequestClaim? = error("not used") + + override suspend fun markStarted(claim: OobeRequestClaim) = error("not used") + override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) = error("not used") + override suspend fun release(claim: OobeRequestClaim, errorCode: String) = error("not used") + override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = error("not used") +} + +private class MutableClock(var now: Instant) : Clock() { + override fun getZone(): ZoneId = ZoneId.of("UTC") + override fun withZone(zone: ZoneId): Clock = this + override fun instant(): Instant = now +} + +private fun request(challenge: ByteArray) = CreateOobeGrantRequest( + challengeId = UUID.randomUUID().toString(), + challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challenge), + keyId = KEY_ID, + installationId = INSTALLATION_ID, + assertion = Base64.getEncoder().encodeToString(byteArrayOf(1)), +) + +private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value) + +private val NOW = Instant.parse("2026-08-21T00:00:00Z") +private val INSTALLATION_ID = UUID.fromString("10000000-0000-0000-0000-000000000001").toString() +private val KEY_ID = Base64.getEncoder().encodeToString(ByteArray(32) { 3 }) diff --git a/src/test/kotlin/com/osglab/account/features/oobe/OobeRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/oobe/OobeRepositoryIntegrationTest.kt new file mode 100644 index 0000000..46a1dd0 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/oobe/OobeRepositoryIntegrationTest.kt @@ -0,0 +1,207 @@ +package com.osglab.account.features.oobe + +import com.osglab.account.config.DatabaseConfig +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayRequestPurpose +import com.osglab.account.features.gateway.models.OobeFeature +import com.osglab.account.features.gateway.models.ProviderUsage +import com.osglab.account.features.gateway.models.UsageMeter +import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner +import com.osglab.account.features.credits.services.CreditService +import com.osglab.account.features.credits.services.ReferralRewardConfig +import com.osglab.account.features.credits.services.signupTrialIdempotencyKey +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.opentest4j.TestAbortedException +import org.testcontainers.DockerClientFactory +import org.testcontainers.containers.MySQLContainer +import java.sql.DriverManager +import java.time.Duration +import java.time.Instant +import java.util.UUID + +class OobeRepositoryIntegrationTest : FunSpec({ + test("anonymous feature claim is atomic, consumed once, and independent from accounts") { + withOobeDatabase { config, databaseFactory -> + val repository = ExposedOobeRepository(databaseFactory) + val now = Instant.parse("2026-08-21T01:00:00Z") + val subject = repository.findOrCreateSubject( + keyId = "integration-key", + installationHash = "a".repeat(64), + subjectId = UUID.randomUUID().toString(), + now = now, + ) + val grant = OobeGrant(UUID.randomUUID().toString(), subject.id, now.plus(Duration.ofMinutes(30))) + repository.createGrant( + NewOobeGrant( + grant = grant, + refreshTokenId = UUID.randomUUID().toString(), + refreshFamilyId = UUID.randomUUID().toString(), + refreshTokenHash = "b".repeat(64), + refreshExpiresAt = grant.expiresAt, + ), + now, + ) + + val claims = coroutineScope { + (1..12).map { index -> + async(Dispatchers.Default) { + repository.claim( + providerRequest(subject.id, grant.id, "concurrent-oobe-$index"), + now.plus(Duration.ofMinutes(15)), + now, + ) + } + }.awaitAll() + } + val winningClaim = claims.filterNotNull().single() + repository.markStarted(winningClaim) + repository.consume( + winningClaim, + ProviderUsage(UsageMeter.LLM_TOKEN, 2, inputUnits = 1, outputUnits = 1), + ) + repository.claim( + providerRequest(subject.id, grant.id, "repeat-after-success"), + now.plus(Duration.ofMinutes(15)), + now, + ) shouldBe null + + databaseCount(config, "accounts") shouldBe 0 + databaseCount(config, "credit_ledger") shouldBe 0 + databaseCount(config, "devicecheck_trial_claims") shouldBe 0 + + val accountId = UUID.randomUUID() + insertAccount(config, accountId, now) + val trial = CreditService( + transactions = ExposedBillingTransactionRunner(databaseFactory.database), + referralRewards = ReferralRewardConfig( + inviterCredits = 1_000, + inviteeCredits = 1_000, + ), + ).grantSignupTrial( + userId = accountId, + credits = 1_000, + idempotencyKey = signupTrialIdempotencyKey(accountId), + ) + trial.balance shouldBe 1_000 + } + } + + test("provider failure releases the feature for a retry") { + withOobeDatabase { _, databaseFactory -> + val repository = ExposedOobeRepository(databaseFactory) + val now = Instant.parse("2026-08-21T02:00:00Z") + val subject = repository.findOrCreateSubject( + "release-key", + "c".repeat(64), + UUID.randomUUID().toString(), + now, + ) + val grant = OobeGrant(UUID.randomUUID().toString(), subject.id, now.plusSeconds(1_800)) + repository.createGrant( + NewOobeGrant( + grant, + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + "d".repeat(64), + grant.expiresAt, + ), + now, + ) + val first = repository.claim( + providerRequest(subject.id, grant.id, "failure-first"), + now.plusSeconds(900), + now, + ) + first shouldNotBe null + repository.markStarted(requireNotNull(first)) + repository.release(first, "provider_failure") + + repository.claim( + providerRequest(subject.id, grant.id, "failure-retry"), + now.plusSeconds(900), + now, + ) shouldNotBe null + } + } +}) + +private fun providerRequest(subjectId: String, grantId: String, requestId: String) = + OobeProviderRequest( + subjectId = subjectId, + grantId = grantId, + feature = OobeFeature.ASK_AI, + requestId = requestId, + providerId = "integration-provider", + capability = GatewayCapability.AI, + purpose = GatewayRequestPurpose.OOBE, + ) + +private suspend fun withOobeDatabase( + block: suspend (DatabaseConfig, DatabaseFactory) -> Unit, +) { + val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank) + if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) { + throw TestAbortedException("Docker is unavailable; MySQL integration test skipped") + } + val mysql = if (externalJdbcUrl == null) { + OobeMySqlContainer("mysql:8.4") + .withDatabaseName("osg_oobe_test") + .withUsername("test") + .withPassword("test") + .also(OobeMySqlContainer::start) + } else { + null + } + val config = DatabaseConfig( + jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl, + username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank) + ?: mysql?.username + ?: "root", + password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "", + maximumPoolSize = 12, + ) + val databaseFactory = DatabaseFactory(config) + try { + databaseFactory.database + block(config, databaseFactory) + } finally { + databaseFactory.close() + mysql?.stop() + } +} + +private fun databaseCount(config: DatabaseConfig, table: String): Long = + DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection -> + connection.createStatement().use { statement -> + statement.executeQuery("SELECT COUNT(*) FROM $table").use { rows -> + rows.next() + rows.getLong(1) + } + } + } + +private fun insertAccount(config: DatabaseConfig, accountId: UUID, now: Instant) { + DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection -> + connection.prepareStatement( + """ + INSERT INTO accounts (id, apple_sub, created_at, updated_at) + VALUES (?, ?, ?, ?) + """.trimIndent(), + ).use { statement -> + statement.setString(1, accountId.toString()) + statement.setString(2, "oobe-signup-$accountId") + statement.setTimestamp(3, java.sql.Timestamp.from(now)) + statement.setTimestamp(4, java.sql.Timestamp.from(now)) + statement.executeUpdate() + } + } +} + +private class OobeMySqlContainer(image: String) : MySQLContainer(image)