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,519 @@
|
||||
package com.osglab.account.config
|
||||
|
||||
import io.ktor.server.config.ApplicationConfig
|
||||
import java.net.URI
|
||||
import java.util.Base64
|
||||
|
||||
data class AppConfig(
|
||||
val environment: Environment,
|
||||
val publicBaseUrl: String,
|
||||
val inviteBaseUrl: String,
|
||||
val appStoreUrl: String,
|
||||
val database: DatabaseConfig,
|
||||
val session: SessionConfig,
|
||||
val encryption: EncryptionConfig,
|
||||
val antiAbuse: AntiAbuseConfig,
|
||||
val apple: AppleConfig,
|
||||
val credits: CreditsConfig,
|
||||
val providers: ProvidersConfig,
|
||||
val integrity: IntegrityConfig,
|
||||
) {
|
||||
val isProduction: Boolean = environment == Environment.PRODUCTION
|
||||
|
||||
companion object {
|
||||
fun from(config: ApplicationConfig): AppConfig {
|
||||
val environment = Environment.parse(config.required("app.environment"))
|
||||
val production = environment == Environment.PRODUCTION
|
||||
|
||||
val databaseUsername = config.required("app.database.username")
|
||||
val databasePassword = config.secret("app.database.password", production)
|
||||
val migrationUsername = if (production) {
|
||||
config.required("app.database.migrationUsername")
|
||||
} else {
|
||||
config.optionalValue("app.database.migrationUsername") ?: databaseUsername
|
||||
}
|
||||
val migrationPassword = config.optionalSecret(
|
||||
"app.database.migrationPassword",
|
||||
production = production,
|
||||
) ?: databasePassword
|
||||
val database = DatabaseConfig(
|
||||
jdbcUrl = config.required("app.database.jdbcUrl"),
|
||||
username = databaseUsername,
|
||||
password = databasePassword,
|
||||
migrationUsername = migrationUsername,
|
||||
migrationPassword = migrationPassword,
|
||||
maximumPoolSize = config.positiveInt("app.database.maximumPoolSize"),
|
||||
)
|
||||
val session = SessionConfig(
|
||||
issuer = config.required("app.session.issuer"),
|
||||
audience = config.required("app.session.audience"),
|
||||
hmacSecret = config.secret("app.session.secret", production).toByteArray(),
|
||||
accessMinutes = config.positiveLong("app.session.accessMinutes"),
|
||||
refreshDays = config.positiveLong("app.session.refreshDays"),
|
||||
gatewayGrantDays = config.positiveLong("app.session.gatewayGrantDays", 30),
|
||||
)
|
||||
val encryption = EncryptionConfig(
|
||||
key = config.base64Key("app.encryption.keyBase64", production),
|
||||
)
|
||||
val antiAbuse = AntiAbuseConfig(
|
||||
identityHmacKey = config.base64Key("app.antiAbuse.identityHmacKeyBase64", production),
|
||||
tombstoneRetentionDays = config.positiveLong(
|
||||
"app.antiAbuse.tombstoneRetentionDays",
|
||||
365,
|
||||
),
|
||||
)
|
||||
val apple = AppleConfig(
|
||||
teamId = config.optionalSecret("app.apple.teamId", production),
|
||||
keyId = config.optionalSecret("app.apple.keyId", production),
|
||||
clientId = config.required("app.apple.clientId"),
|
||||
privateKeyPem = config.optionalSecret("app.apple.privateKeyPem", production)
|
||||
?.replace("\\n", "\n"),
|
||||
jwksUrl = config.httpsUrl("app.apple.jwksUrl", production),
|
||||
tokenUrl = config.httpsUrl("app.apple.tokenUrl", production),
|
||||
revokeUrl = config.httpsUrl("app.apple.revokeUrl", production),
|
||||
)
|
||||
val credits = CreditsConfig(
|
||||
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
|
||||
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
|
||||
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000),
|
||||
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
|
||||
)
|
||||
val providers = ProvidersConfig(
|
||||
volcengine = VolcengineConfig(
|
||||
endpoint = config.valueOrDefault(
|
||||
"app.providers.volcengine.endpoint",
|
||||
"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
|
||||
),
|
||||
appId = config.optionalValue("app.providers.volcengine.appId"),
|
||||
accessToken = config.optionalValue("app.providers.volcengine.accessToken"),
|
||||
apiKey = config.optionalValue("app.providers.volcengine.apiKey"),
|
||||
resourceId = config.valueOrDefault(
|
||||
"app.providers.volcengine.resourceId",
|
||||
"volc.seedasr.sauc.duration",
|
||||
),
|
||||
),
|
||||
deepSeek = DeepSeekConfig(
|
||||
endpoint = config.valueOrDefault(
|
||||
"app.providers.deepseek.endpoint",
|
||||
"https://api.deepseek.com/v1",
|
||||
),
|
||||
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
|
||||
model = config.valueOrDefault("app.providers.deepseek.model", "deepseek-v4-flash"),
|
||||
),
|
||||
)
|
||||
val integrity = IntegrityConfig(
|
||||
deviceCheckPolicy = IntegrityPolicy.fromEnforced(
|
||||
config.boolean("app.integrity.enforceDeviceCheck"),
|
||||
),
|
||||
appAttestPolicy = IntegrityPolicy.fromEnforced(
|
||||
config.boolean("app.integrity.enforceAppAttest"),
|
||||
),
|
||||
appleEnvironment = AppleServiceEnvironment.parse(
|
||||
config.valueOrDefault(
|
||||
"app.integrity.appleEnvironment",
|
||||
if (production) "production" else "development",
|
||||
),
|
||||
),
|
||||
challengeLifetimeSeconds = config.positiveLong(
|
||||
"app.integrity.challengeLifetimeSeconds",
|
||||
300,
|
||||
),
|
||||
)
|
||||
|
||||
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
|
||||
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes"
|
||||
}
|
||||
require(encryption.key.size == AES_256_KEY_BYTES) {
|
||||
"app.encryption.keyBase64 must decode to exactly $AES_256_KEY_BYTES bytes"
|
||||
}
|
||||
require(antiAbuse.identityHmacKey.size >= MIN_HMAC_SECRET_BYTES) {
|
||||
"app.antiAbuse.identityHmacKeyBase64 must decode to at least $MIN_HMAC_SECRET_BYTES bytes"
|
||||
}
|
||||
require(
|
||||
!session.hmacSecret.contentEquals(encryption.key) &&
|
||||
!session.hmacSecret.contentEquals(antiAbuse.identityHmacKey) &&
|
||||
!encryption.key.contentEquals(antiAbuse.identityHmacKey)
|
||||
) {
|
||||
"Session, field-encryption, and identity-HMAC secrets must be distinct"
|
||||
}
|
||||
require(database.jdbcUrl.startsWith("jdbc:mysql:")) {
|
||||
"app.database.jdbcUrl must use MySQL"
|
||||
}
|
||||
require(
|
||||
!production ||
|
||||
(!database.migrationUsername.isPlaceholder() &&
|
||||
database.migrationUsername != database.username)
|
||||
) {
|
||||
"Production migration and runtime database users must be distinct"
|
||||
}
|
||||
require(!production || database.migrationPassword != database.password) {
|
||||
"Production migration and runtime database passwords must be distinct"
|
||||
}
|
||||
require(database.maximumPoolSize in 1..100) {
|
||||
"app.database.maximumPoolSize must be between 1 and 100"
|
||||
}
|
||||
require(session.accessMinutes in 1..60) {
|
||||
"app.session.accessMinutes must be between 1 and 60"
|
||||
}
|
||||
require(session.refreshDays in 1..365) {
|
||||
"app.session.refreshDays must be between 1 and 365"
|
||||
}
|
||||
require(!production || providers.volcengine.credentialsAvailable) {
|
||||
"Production Volcengine credentials are missing"
|
||||
}
|
||||
require(!production || providers.deepSeek.credentialsAvailable) {
|
||||
"Production DeepSeek credentials are missing"
|
||||
}
|
||||
if (production) {
|
||||
requireExactProviderEndpoint(
|
||||
providers.deepSeek.endpoint,
|
||||
"https",
|
||||
"api.deepseek.com",
|
||||
"DeepSeek",
|
||||
)
|
||||
requireExactProviderEndpoint(
|
||||
providers.volcengine.endpoint,
|
||||
"wss",
|
||||
"openspeech.bytedance.com",
|
||||
"Volcengine",
|
||||
)
|
||||
}
|
||||
require(integrity.challengeLifetimeSeconds in 30..600) {
|
||||
"app.integrity.challengeLifetimeSeconds must be between 30 and 600 seconds"
|
||||
}
|
||||
require(!production || integrity.appleEnvironment == AppleServiceEnvironment.PRODUCTION) {
|
||||
"Production must use the Apple production integrity environment"
|
||||
}
|
||||
require(
|
||||
!production ||
|
||||
(
|
||||
integrity.deviceCheckPolicy == IntegrityPolicy.ENFORCE &&
|
||||
integrity.appAttestPolicy == IntegrityPolicy.ENFORCE
|
||||
)
|
||||
) {
|
||||
"Production must enforce both DeviceCheck and App Attest"
|
||||
}
|
||||
require(!production || apple.teamId == APP_ATTEST_TEAM_ID) {
|
||||
"Production Apple team ID must be $APP_ATTEST_TEAM_ID"
|
||||
}
|
||||
require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) {
|
||||
"Production Apple client ID must be $APP_ATTEST_BUNDLE_ID"
|
||||
}
|
||||
if (production) {
|
||||
requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS")
|
||||
requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token")
|
||||
requireExactAppleEndpoint(apple.revokeUrl, "/auth/revoke", "revoke")
|
||||
}
|
||||
|
||||
val publicBaseUrl = config.productionValueOrDefault(
|
||||
"app.publicBaseUrl",
|
||||
"http://localhost:8080",
|
||||
production,
|
||||
).validatedExternalUrl("app.publicBaseUrl", production)
|
||||
val inviteBaseUrl = config.productionValueOrDefault(
|
||||
"app.inviteBaseUrl",
|
||||
"https://osglab.com/i",
|
||||
production,
|
||||
).validatedExternalUrl("app.inviteBaseUrl", production)
|
||||
val appStoreUrl = config.productionValueOrDefault(
|
||||
"app.appStoreUrl",
|
||||
"https://apps.apple.com",
|
||||
production,
|
||||
).validatedExternalUrl("app.appStoreUrl", production)
|
||||
if (production) {
|
||||
requireExactExternalUrl(publicBaseUrl, "account.osglab.com", "", "PUBLIC_BASE_URL")
|
||||
requireExactExternalUrl(inviteBaseUrl, "osglab.com", "/i", "INVITE_BASE_URL")
|
||||
requireOfficialAppStoreUrl(appStoreUrl)
|
||||
}
|
||||
|
||||
return AppConfig(
|
||||
environment = environment,
|
||||
publicBaseUrl = publicBaseUrl,
|
||||
inviteBaseUrl = inviteBaseUrl,
|
||||
appStoreUrl = appStoreUrl,
|
||||
database = database,
|
||||
session = session,
|
||||
encryption = encryption,
|
||||
antiAbuse = antiAbuse,
|
||||
apple = apple,
|
||||
credits = credits,
|
||||
providers = providers,
|
||||
integrity = integrity,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Environment {
|
||||
DEVELOPMENT,
|
||||
TEST,
|
||||
PRODUCTION;
|
||||
|
||||
companion object {
|
||||
fun parse(value: String): Environment = entries.firstOrNull {
|
||||
it.name.equals(value, ignoreCase = true)
|
||||
} ?: throw ConfigValidationException("Unsupported app.environment: $value")
|
||||
}
|
||||
}
|
||||
|
||||
data class DatabaseConfig(
|
||||
val jdbcUrl: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val maximumPoolSize: Int,
|
||||
val migrationUsername: String = username,
|
||||
val migrationPassword: String = password,
|
||||
)
|
||||
|
||||
data class SessionConfig(
|
||||
val issuer: String,
|
||||
val audience: String,
|
||||
val hmacSecret: ByteArray,
|
||||
val accessMinutes: Long,
|
||||
val refreshDays: Long,
|
||||
val gatewayGrantDays: Long = 30,
|
||||
)
|
||||
|
||||
data class EncryptionConfig(val key: ByteArray)
|
||||
|
||||
data class AntiAbuseConfig(
|
||||
val identityHmacKey: ByteArray,
|
||||
val tombstoneRetentionDays: Long,
|
||||
)
|
||||
|
||||
data class AppleConfig(
|
||||
val teamId: String?,
|
||||
val keyId: String?,
|
||||
val clientId: String,
|
||||
val privateKeyPem: String?,
|
||||
val jwksUrl: String,
|
||||
val tokenUrl: String,
|
||||
val revokeUrl: String,
|
||||
) {
|
||||
val clientCredentialsAvailable: Boolean
|
||||
get() = teamId != null && keyId != null && privateKeyPem != null
|
||||
}
|
||||
|
||||
data class CreditsConfig(
|
||||
val signupTrial: Long,
|
||||
val referralInviter: Long,
|
||||
val referralInvitee: Long,
|
||||
val referralBindingDays: Long,
|
||||
)
|
||||
|
||||
data class ProvidersConfig(
|
||||
val volcengine: VolcengineConfig,
|
||||
val deepSeek: DeepSeekConfig,
|
||||
)
|
||||
|
||||
data class VolcengineConfig(
|
||||
val endpoint: String,
|
||||
val appId: String?,
|
||||
val accessToken: String?,
|
||||
val apiKey: String?,
|
||||
val resourceId: String,
|
||||
) {
|
||||
val credentialsAvailable: Boolean
|
||||
get() = !apiKey.isNullOrBlank() || (!appId.isNullOrBlank() && !accessToken.isNullOrBlank())
|
||||
}
|
||||
|
||||
data class DeepSeekConfig(
|
||||
val endpoint: String,
|
||||
val apiKey: String?,
|
||||
val model: String,
|
||||
) {
|
||||
val credentialsAvailable: Boolean
|
||||
get() = !apiKey.isNullOrBlank()
|
||||
}
|
||||
|
||||
data class IntegrityConfig(
|
||||
val deviceCheckPolicy: IntegrityPolicy,
|
||||
val appAttestPolicy: IntegrityPolicy,
|
||||
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
|
||||
val challengeLifetimeSeconds: Long = 300,
|
||||
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
|
||||
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
|
||||
)
|
||||
|
||||
enum class IntegrityPolicy {
|
||||
MONITOR,
|
||||
ENFORCE;
|
||||
|
||||
companion object {
|
||||
fun fromEnforced(enforced: Boolean): IntegrityPolicy = if (enforced) ENFORCE else MONITOR
|
||||
}
|
||||
}
|
||||
|
||||
enum class AppleServiceEnvironment {
|
||||
DEVELOPMENT,
|
||||
PRODUCTION;
|
||||
|
||||
companion object {
|
||||
fun parse(value: String): AppleServiceEnvironment = entries.firstOrNull {
|
||||
it.name.equals(value, ignoreCase = true)
|
||||
} ?: throw ConfigValidationException("Unsupported Apple integrity environment: $value")
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigValidationException(message: String, cause: Throwable? = null) :
|
||||
IllegalStateException(message, cause)
|
||||
|
||||
private const val MIN_HMAC_SECRET_BYTES = 32
|
||||
private const val AES_256_KEY_BYTES = 32
|
||||
const val APP_ATTEST_TEAM_ID = "X329MZU23S"
|
||||
const val APP_ATTEST_BUNDLE_ID = "com.osgkeyboard.ios"
|
||||
private val PLACEHOLDER_MARKERS = listOf("replace-with", "change-me", "\${", "$")
|
||||
|
||||
private fun ApplicationConfig.required(path: String): String =
|
||||
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
|
||||
?: throw ConfigValidationException("Missing required configuration: $path")
|
||||
|
||||
private fun ApplicationConfig.valueOrDefault(path: String, default: String): String =
|
||||
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) ?: default
|
||||
|
||||
private fun ApplicationConfig.productionValueOrDefault(
|
||||
path: String,
|
||||
default: String,
|
||||
production: Boolean,
|
||||
): String = if (production) required(path) else valueOrDefault(path, default)
|
||||
|
||||
private fun ApplicationConfig.optionalValue(path: String): String? =
|
||||
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
|
||||
?.takeUnless(String::isPlaceholder)
|
||||
|
||||
private fun ApplicationConfig.secret(path: String, production: Boolean): String =
|
||||
optionalSecret(path, production)
|
||||
?: throw ConfigValidationException("Missing required secret: $path")
|
||||
|
||||
private fun ApplicationConfig.optionalSecret(path: String, production: Boolean): String? {
|
||||
val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
|
||||
if (production && (value == null || value.isPlaceholder())) {
|
||||
throw ConfigValidationException("Production secret is missing or uses a placeholder: $path")
|
||||
}
|
||||
return value?.takeUnless(String::isPlaceholder)
|
||||
}
|
||||
|
||||
private fun String.isPlaceholder(): Boolean =
|
||||
PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) }
|
||||
|
||||
private fun ApplicationConfig.positiveInt(path: String): Int =
|
||||
required(path).toIntOrNull()?.takeIf { it > 0 }
|
||||
?: throw ConfigValidationException("$path must be a positive integer")
|
||||
|
||||
private fun ApplicationConfig.positiveLong(path: String): Long =
|
||||
required(path).toLongOrNull()?.takeIf { it > 0 }
|
||||
?: throw ConfigValidationException("$path must be a positive integer")
|
||||
|
||||
private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
|
||||
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let {
|
||||
it.toLongOrNull()?.takeIf { value -> value > 0 }
|
||||
?: throw ConfigValidationException("$path must be a positive integer")
|
||||
} ?: default
|
||||
|
||||
private fun ApplicationConfig.boolean(path: String): Boolean =
|
||||
required(path).let {
|
||||
when (it.lowercase()) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> throw ConfigValidationException("$path must be true or false")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray {
|
||||
val encoded = secret(path, production)
|
||||
return try {
|
||||
Base64.getDecoder().decode(encoded)
|
||||
} catch (exception: IllegalArgumentException) {
|
||||
throw ConfigValidationException("$path must be valid Base64", exception)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ApplicationConfig.httpsUrl(path: String, production: Boolean): String {
|
||||
val value = required(path)
|
||||
val uri = runCatching { URI(value) }
|
||||
.getOrElse { throw ConfigValidationException("$path must be a valid URL", it) }
|
||||
require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) {
|
||||
"$path must be an absolute URL without user information"
|
||||
}
|
||||
require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) {
|
||||
"$path must use HTTP or HTTPS"
|
||||
}
|
||||
require(!production || uri.scheme.equals("https", ignoreCase = true)) {
|
||||
"$path must use HTTPS in production"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun String.validatedExternalUrl(path: String, production: Boolean): String {
|
||||
val uri = runCatching { URI(this) }
|
||||
.getOrElse { throw ConfigValidationException("$path must be a valid URL", it) }
|
||||
require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) {
|
||||
"$path must be an absolute URL without user information"
|
||||
}
|
||||
require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) {
|
||||
"$path must use HTTPS${if (production) "" else " or HTTP"}"
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
private fun requireExactAppleEndpoint(value: String, path: String, name: String) {
|
||||
val uri = URI(value)
|
||||
require(
|
||||
uri.scheme.equals("https", true) &&
|
||||
uri.host.equals("appleid.apple.com", true) &&
|
||||
uri.port == -1 &&
|
||||
uri.path == path &&
|
||||
uri.rawQuery == null &&
|
||||
uri.rawFragment == null
|
||||
) {
|
||||
"Apple $name endpoint must be https://appleid.apple.com$path"
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireExactProviderEndpoint(
|
||||
value: String,
|
||||
scheme: String,
|
||||
host: String,
|
||||
provider: String,
|
||||
) {
|
||||
val uri = runCatching { URI(value) }
|
||||
.getOrElse { throw ConfigValidationException("$provider endpoint is invalid", it) }
|
||||
require(
|
||||
uri.scheme.equals(scheme, ignoreCase = true) &&
|
||||
uri.host.equals(host, ignoreCase = true) &&
|
||||
uri.userInfo == null &&
|
||||
(uri.port == -1 || uri.port == 443)
|
||||
) {
|
||||
"$provider endpoint must use $scheme://$host on the default TLS port"
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireExactExternalUrl(value: String, host: String, path: String, name: String) {
|
||||
val uri = URI(value)
|
||||
require(
|
||||
uri.scheme.equals("https", ignoreCase = true) &&
|
||||
uri.host.equals(host, ignoreCase = true) &&
|
||||
uri.port == -1 &&
|
||||
uri.path.trimEnd('/') == path &&
|
||||
uri.rawQuery == null &&
|
||||
uri.rawFragment == null
|
||||
) {
|
||||
"$name must be https://$host$path"
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireOfficialAppStoreUrl(value: String) {
|
||||
val uri = URI(value)
|
||||
require(
|
||||
uri.scheme.equals("https", ignoreCase = true) &&
|
||||
uri.host.equals("apps.apple.com", ignoreCase = true) &&
|
||||
uri.port == -1 &&
|
||||
uri.userInfo == null &&
|
||||
uri.rawFragment == null &&
|
||||
APP_STORE_APP_PATH.matches(uri.path)
|
||||
) {
|
||||
"APP_STORE_URL must be an official apps.apple.com URL ending in a non-zero numeric App ID"
|
||||
}
|
||||
}
|
||||
|
||||
private val APP_STORE_APP_PATH = Regex("/.+/id[1-9][0-9]*")
|
||||
Reference in New Issue
Block a user