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,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