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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user