Establish secure account and managed AI backend

Provide the production foundation for Apple identity, immutable credits, referrals, integrity checks, managed providers, and hardened Docker deployment.
This commit is contained in:
Rocky
2026-08-16 14:46:23 +08:00
commit 0af35d44f4
124 changed files with 21052 additions and 0 deletions
@@ -0,0 +1,59 @@
package com.osglab.account.common.api
import com.osglab.account.common.errors.ApiException
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.plugins.statuspages.exception
import io.ktor.server.response.respond
import io.ktor.util.AttributeKey
import kotlinx.serialization.Serializable
@Serializable
data class ApiResponse<T>(val data: T)
@Serializable
data class ApiErrorResponse(val error: ApiError)
@Serializable
data class ApiError(
val code: String,
val message: String,
)
fun Application.installApiStatusPages() {
install(StatusPages) {
status(HttpStatusCode.Unauthorized) { call, status ->
if (!call.attributes.contains(API_ERROR_HANDLED)) {
call.respond(
status,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
}
}
exception<ApiException> { call, cause ->
call.attributes.put(API_ERROR_HANDLED, true)
call.respond(
cause.status,
ApiErrorResponse(ApiError(cause.code, cause.message)),
)
}
exception<BadRequestException> { call, _ ->
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "Request body is invalid")),
)
}
exception<Throwable> { call, _ ->
call.respond(
HttpStatusCode.InternalServerError,
ApiErrorResponse(ApiError("internal_error", "An internal error occurred")),
)
}
}
}
private val API_ERROR_HANDLED = AttributeKey<Boolean>("api-error-handled")
@@ -0,0 +1,65 @@
package com.osglab.account.common.errors
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.plugins.statuspages.exception
import io.ktor.server.response.respond
import kotlinx.serialization.Serializable
@Serializable
data class ApiErrorResponse(val error: ApiError)
@Serializable
data class ApiError(
val code: String,
val message: String,
)
open class ApiException(
val status: HttpStatusCode,
val code: String,
override val message: String,
) : RuntimeException(message)
class InvalidRequestException(message: String) :
ApiException(HttpStatusCode.BadRequest, "invalid_request", message)
class UnauthorizedException(message: String = "Authentication required") :
ApiException(HttpStatusCode.Unauthorized, "unauthorized", message)
class TokenReuseException :
ApiException(
HttpStatusCode.Unauthorized,
"refresh_token_reuse",
"Refresh token reuse detected; the session family has been revoked",
)
class ExternalServiceUnavailableException(service: String) :
ApiException(
HttpStatusCode.ServiceUnavailable,
"external_service_unavailable",
"$service is temporarily unavailable",
)
class ConflictException(message: String) :
ApiException(HttpStatusCode.Conflict, "conflict", message)
fun Application.installApiErrors() {
install(StatusPages) {
exception<ApiException> { call, cause ->
call.respond(
cause.status,
ApiErrorResponse(ApiError(cause.code, cause.message)),
)
}
exception<Throwable> { call, _ ->
call.respond(
HttpStatusCode.InternalServerError,
ApiErrorResponse(ApiError("internal_error", "An internal error occurred")),
)
}
}
}
@@ -0,0 +1,61 @@
package com.osglab.account.common.security
import java.security.GeneralSecurityException
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class FieldEncryptor(
key: ByteArray,
private val secureRandom: SecureRandom = SecureRandom(),
) {
private val keySpec: SecretKeySpec
init {
require(key.size == AES_KEY_BYTES) { "AES-GCM requires a 32-byte key" }
keySpec = SecretKeySpec(key.copyOf(), "AES")
}
fun encrypt(plaintext: String, context: String): String {
require(context.isNotBlank()) { "Encryption context must not be blank" }
val iv = ByteArray(GCM_IV_BYTES).also(secureRandom::nextBytes)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(context.toByteArray(Charsets.UTF_8))
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val encoder = Base64.getUrlEncoder().withoutPadding()
return listOf(VERSION, encoder.encodeToString(iv), encoder.encodeToString(ciphertext))
.joinToString(".")
}
fun decrypt(value: String, context: String): String {
require(context.isNotBlank()) { "Encryption context must not be blank" }
val parts = value.split('.')
require(parts.size == 3 && parts[0] == VERSION) { "Unsupported encrypted field format" }
return try {
val decoder = Base64.getUrlDecoder()
val iv = decoder.decode(parts[1])
require(iv.size == GCM_IV_BYTES) { "Invalid AES-GCM IV" }
val ciphertext = decoder.decode(parts[2])
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, keySpec, GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(context.toByteArray(Charsets.UTF_8))
cipher.doFinal(ciphertext).toString(Charsets.UTF_8)
} catch (exception: GeneralSecurityException) {
throw FieldDecryptionException(exception)
} catch (exception: IllegalArgumentException) {
throw FieldDecryptionException(exception)
}
}
}
class FieldDecryptionException(cause: Throwable) :
IllegalStateException("Encrypted field authentication failed", cause)
private const val VERSION = "v1"
private const val AES_KEY_BYTES = 32
private const val GCM_IV_BYTES = 12
private const val GCM_TAG_BITS = 128
private const val TRANSFORMATION = "AES/GCM/NoPadding"
@@ -0,0 +1,34 @@
package com.osglab.account.common.security
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Produces a non-reversible, deployment-specific identifier for anti-abuse history.
* The HMAC key must be independent from the field-encryption and session keys.
*/
class IdentityFingerprint(
key: ByteArray,
) {
private val keySpec: SecretKeySpec
init {
require(key.size >= MIN_KEY_BYTES) {
"Identity fingerprint HMAC key must contain at least $MIN_KEY_BYTES bytes"
}
keySpec = SecretKeySpec(key.copyOf(), HMAC_ALGORITHM)
}
fun ofAppleSubject(subject: String): String {
require(subject.isNotBlank()) { "Apple subject must not be blank" }
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(keySpec)
return mac.doFinal(subject.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
}
private companion object {
const val HMAC_ALGORITHM = "HmacSHA256"
const val MIN_KEY_BYTES = 32
}
}
@@ -0,0 +1,116 @@
package com.osglab.account.common.security
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.JOSEObjectType
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.config.SessionConfig
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.bearer
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Date
import java.util.UUID
data class IssuedAccessToken(
val value: String,
val expiresAt: Instant,
) {
override fun toString(): String = "IssuedAccessToken(value=[REDACTED], expiresAt=$expiresAt)"
}
data class AccountPrincipal(
val userId: UUID,
val sessionId: UUID,
) {
// Compatibility name used by the existing credits and gateway adapters.
val accountId: UUID
get() = userId
}
@Deprecated("Use AccountPrincipal", ReplaceWith("AccountPrincipal"))
typealias SessionPrincipal = AccountPrincipal
class SessionJwt(
private val config: SessionConfig,
private val clock: Clock = Clock.systemUTC(),
) {
init {
require(config.hmacSecret.size >= MIN_HMAC_BYTES) {
"Session HMAC secret must contain at least $MIN_HMAC_BYTES bytes"
}
}
fun issue(userId: UUID, sessionId: UUID): IssuedAccessToken {
val now = clock.instant()
val expiresAt = now.plus(Duration.ofMinutes(config.accessMinutes))
val tokenId = UUID.randomUUID()
val claims = JWTClaimsSet.Builder()
.issuer(config.issuer)
.audience(config.audience)
.subject(userId.toString())
.jwtID(tokenId.toString())
.issueTime(Date.from(now))
.notBeforeTime(Date.from(now.minusSeconds(CLOCK_SKEW_SECONDS)))
.expirationTime(Date.from(expiresAt))
.claim(CLAIM_TYPE, ACCESS_TOKEN_TYPE)
.claim(CLAIM_SESSION, sessionId.toString())
.build()
val jwt = SignedJWT(
JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JWT).build(),
claims,
)
jwt.sign(MACSigner(config.hmacSecret))
return IssuedAccessToken(jwt.serialize(), expiresAt)
}
fun verify(serialized: String): AccountPrincipal? = runCatching {
require(serialized.isNotBlank() && serialized.length <= MAX_ACCESS_TOKEN_LENGTH)
val jwt = SignedJWT.parse(serialized)
require(jwt.header.algorithm == JWSAlgorithm.HS256)
require(jwt.header.type == JOSEObjectType.JWT)
require(jwt.header.criticalParams.isNullOrEmpty())
require(jwt.verify(MACVerifier(config.hmacSecret)))
val claims = jwt.jwtClaimsSet
val now = clock.instant()
require(claims.issuer == config.issuer)
require(claims.audience == listOf(config.audience))
require(claims.getStringClaim(CLAIM_TYPE) == ACCESS_TOKEN_TYPE)
val expiresAt = requireNotNull(claims.expirationTime).toInstant()
val notBefore = requireNotNull(claims.notBeforeTime).toInstant()
val issuedAt = requireNotNull(claims.issueTime).toInstant()
require(expiresAt.isAfter(now.minusSeconds(CLOCK_SKEW_SECONDS)))
require(notBefore.isBefore(now.plusSeconds(CLOCK_SKEW_SECONDS)))
require(!issuedAt.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)))
require(expiresAt.isAfter(issuedAt))
UUID.fromString(requireNotNull(claims.jwtid))
AccountPrincipal(
userId = UUID.fromString(claims.subject),
sessionId = UUID.fromString(claims.getStringClaim(CLAIM_SESSION)),
)
}.getOrNull()
}
fun Application.installSessionAuthentication(
authenticateToken: suspend (String) -> AccountPrincipal?,
) {
install(Authentication) {
bearer(SESSION_AUTH_NAME) {
authenticate { credential -> authenticateToken(credential.token) }
}
}
}
const val SESSION_AUTH_NAME = "session"
private const val CLAIM_TYPE = "typ"
private const val CLAIM_SESSION = "sid"
private const val ACCESS_TOKEN_TYPE = "access"
private const val MIN_HMAC_BYTES = 32
private const val MAX_ACCESS_TOKEN_LENGTH = 4_096
private const val CLOCK_SKEW_SECONDS = 30L
@@ -0,0 +1,40 @@
package com.osglab.account.common.security
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
typealias RefreshTokenGenerator = Sha256SecureTokenGenerator
fun interface SecureTokenGenerator {
fun newRefreshToken(): String
}
/**
* Generates a 256-bit opaque token. Callers persist only its SHA-256 digest.
*/
class Sha256SecureTokenGenerator(
private val secureRandom: SecureRandom = SecureRandom(),
) : SecureTokenGenerator {
override fun newRefreshToken(): String =
ByteArray(REFRESH_TOKEN_BYTES)
.also(secureRandom::nextBytes)
.let(Base64.getUrlEncoder().withoutPadding()::encodeToString)
private companion object {
const val REFRESH_TOKEN_BYTES = 32
}
}
object TokenHash {
fun sha256(token: String): String =
MessageDigest.getInstance("SHA-256")
.digest(token.toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
fun matches(token: String, expectedHex: String): Boolean {
val actual = sha256(token).toByteArray(Charsets.US_ASCII)
val expected = expectedHex.lowercase().toByteArray(Charsets.US_ASCII)
return MessageDigest.isEqual(actual, expected)
}
}