Skip to content

Commit

Permalink
[WAL-1391] Auth header sig (#128)
Browse files Browse the repository at this point in the history
* WAL-1391: Auth header signature

* Simplify impl

* Move query params into claims

* Remove iss

* Small fix

* Make fun public

* Replace host/path with aud

* Replace aud with web_auth_endpoint

* Add domain auth header signer

* Detekt

* bump version

* Add auth header signer
  • Loading branch information
Ifropc authored Apr 1, 2024
1 parent 5d2b710 commit 592f5e6
Show file tree
Hide file tree
Showing 7 changed files with 302 additions and 14 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ val jvmVersion = JavaVersion.VERSION_11

allprojects {
group = "org.stellar.wallet-sdk"
version = "1.3.0"
version = "1.4.0"
}

subprojects {
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
[versions]
# Library versions
bcastle = "1.77"
coroutines = "1.6.4"
google-gson = "2.8.9"
hoplite = "2.7.0"
jjwt = "0.12.5"
java-stellar-sdk = "0.42.0"
dokka = "1.6.10"
kotlin = "1.8.20"
Expand All @@ -20,12 +22,14 @@ spotless = "6.9.1"
detekt = "1.22.0"

[libraries]
bcastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcastle" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
google-gson = { module = "com.google.code.gson:gson", version.ref = "google-gson" }
hoplite-core = { module = "com.sksamuel.hoplite:hoplite-core", version.ref = "hoplite" }
hoplite-yaml = { module = "com.sksamuel.hoplite:hoplite-yaml", version.ref = "hoplite" }
java-stellar-sdk = { module = "com.github.stellar:java-stellar-sdk", version.ref = "java-stellar-sdk" }
jjwt = { module = "io.jsonwebtoken:jjwt", version.ref = "jjwt" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlin-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-json" }
Expand Down
2 changes: 2 additions & 0 deletions wallet-sdk/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ dependencies {
implementation(libs.kotlin.logging)
implementation(libs.google.gson)
implementation(libs.toml4j)
implementation(libs.jjwt)
implementation(libs.bcastle)

testImplementation(libs.coroutines.test)
testImplementation(libs.kotlin.junit)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package org.stellar.walletsdk.auth

import io.jsonwebtoken.JwtBuilder
import io.jsonwebtoken.Jwts
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import java.time.Instant
import java.util.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlinx.datetime.Clock
import kotlinx.serialization.Serializable
import org.stellar.walletsdk.horizon.AccountKeyPair
import org.stellar.walletsdk.horizon.SigningKeyPair
import org.stellar.walletsdk.util.Util.postJson
import org.stellar.walletsdk.util.toJava

/** Header signer to sign JWT for GET /Auth request. */
interface AuthHeaderSigner {
suspend fun createToken(
claims: Map<String, String>,
clientDomain: String?,
issuer: AccountKeyPair?
): String
}

/** Header signer signing JWT for GET /Auth with a main custodial key */
open class DefaultAuthHeaderSigner(open val expiration: Duration = 15.minutes) : AuthHeaderSigner {
override suspend fun createToken(
claims: Map<String, String>,
clientDomain: String?,
issuer: AccountKeyPair?
): String {
require(issuer != null) { "Default signer can't sign headers for client domain." }
require(issuer is SigningKeyPair) {
"SigningKeyPair must be provided to .auth() method in order to sign headers"
}

val timeExp = Instant.ofEpochSecond(Clock.System.now().plus(expiration).epochSeconds)
val builder = createBuilder(timeExp, claims)

builder.signWith(issuer.toJava().private, Jwts.SIG.EdDSA)

return builder.compact()
}

fun createBuilder(timeExp: Instant, claims: Map<String, String>): JwtBuilder {
return Jwts.builder().issuedAt(Date.from(Instant.now())).expiration(Date.from(timeExp)).also {
builder ->
claims.forEach { builder.claim(it.key, it.value) }
}
}
}

/**
* Auth header signer using remote server to form and sign the JWT. On calling [createToken] it will
* send [JWTSignData] to the specified [url], expecting [SignedJWT] in response.
*/
open class DomainAuthHeaderSigner(
val url: String,
val requestTransformer: HttpRequestBuilder.() -> Unit = {},
override val expiration: Duration = 15.minutes
) : DefaultAuthHeaderSigner(expiration) {
val client = HttpClient() { install(ContentNegotiation) { json() } }

@Serializable
data class JWTSignData(
val claims: Map<String, String>,
val clientDomain: String,
val exp: Long,
val iat: Long
)

@Serializable data class SignedJWT(val token: String)

override suspend fun createToken(
claims: Map<String, String>,
clientDomain: String?,
issuer: AccountKeyPair?
): String {
require(clientDomain != null) {
"This signed should only be used for remote signing. For local signing use " +
"${DefaultAuthHeaderSigner::class.simpleName} instead"
}
val now = Clock.System.now()
val timeExp = now.plus(expiration).epochSeconds
return signToken(claims, clientDomain, timeExp, now.epochSeconds)
}

/**
* Method to create and sign token on a remote server
*
* @return signed base-64 encoded JWT token
*/
open suspend fun signToken(
claims: Map<String, String>,
clientDomain: String,
expiration: Long,
issuedAt: Long
): String {
val response: SignedJWT =
client.postJson(url, JWTSignData(claims, clientDomain, expiration, issuedAt)) {
requestTransformer()
}

return response.token
}
}
59 changes: 48 additions & 11 deletions wallet-sdk/src/main/kotlin/org/stellar/walletsdk/auth/Sep10.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import org.stellar.sdk.Transaction
import org.stellar.walletsdk.Config
import org.stellar.walletsdk.exception.*
import org.stellar.walletsdk.horizon.AccountKeyPair
import org.stellar.walletsdk.util.Util.authGet
import org.stellar.walletsdk.util.Util.authGetStringToken
import org.stellar.walletsdk.util.Util.postJson

private val log = KotlinLogging.logger {}
Expand Down Expand Up @@ -44,10 +44,16 @@ internal constructor(
accountAddress: AccountKeyPair,
walletSigner: WalletSigner? = null,
memoId: ULong? = null,
clientDomain: String? = null
clientDomain: String? = null,
authHeaderSigner: AuthHeaderSigner? = null
): AuthToken {
val challengeTxn =
challenge(accountAddress, memoId?.toString(), clientDomain ?: cfg.app.defaultClientDomain)
challenge(
accountAddress,
memoId?.toString(),
clientDomain ?: cfg.app.defaultClientDomain,
authHeaderSigner
)
val signedTxn = sign(accountAddress, challengeTxn, walletSigner ?: cfg.app.defaultSigner)
return getToken(signedTxn)
}
Expand All @@ -56,10 +62,16 @@ internal constructor(
accountAddress: AccountKeyPair,
walletSigner: WalletSigner? = null,
memoId: BigInteger? = null,
clientDomain: String? = null
clientDomain: String? = null,
authHeaderSigner: AuthHeaderSigner? = null
): AuthToken {
val challengeTxn =
challenge(accountAddress, memoId?.toString(), clientDomain ?: cfg.app.defaultClientDomain)
challenge(
accountAddress,
memoId?.toString(),
clientDomain ?: cfg.app.defaultClientDomain,
authHeaderSigner
)
val signedTxn = sign(accountAddress, challengeTxn, walletSigner ?: cfg.app.defaultSigner)
return getToken(signedTxn)
}
Expand All @@ -77,27 +89,35 @@ internal constructor(
private suspend fun challenge(
account: AccountKeyPair,
memoId: String? = null,
clientDomain: String? = null
clientDomain: String? = null,
authHeaderSigner: AuthHeaderSigner?
): ChallengeResponse {
val url = URLBuilder(webAuthEndpoint)

// Add required query params
url.parameters.append("account", account.address)
url.parameters.append("home_domain", homeDomain)
val parameters = mutableMapOf<String, String>()
parameters["account"] = account.address
parameters["home_domain"] = homeDomain

if (memoId != null) {
url.parameters.append("memo", memoId)
parameters["memo"] = memoId
}

if (!clientDomain.isNullOrBlank()) {
url.parameters.append("client_domain", clientDomain)
parameters["client_domain"] = clientDomain
}

parameters.forEach { url.parameters.append(it.key, it.value) }

log.debug {
"Challenge request: account = $account, memo = $memoId, client_domain = $clientDomain"
}

val jsonResponse = httpClient.authGet<ChallengeResponse>(url.build().toString())
val token =
createAuthSignToken(account, webAuthEndpoint, parameters, clientDomain, authHeaderSigner)

val jsonResponse =
httpClient.authGetStringToken<ChallengeResponse>(url.build().toString(), token)

if (jsonResponse.transaction.isBlank()) {
throw MissingTransactionException
Expand Down Expand Up @@ -178,3 +198,20 @@ internal constructor(
return token
}
}

suspend fun createAuthSignToken(
account: AccountKeyPair,
webAuthEndpoint: String,
parameters: Map<String, String>,
clientDomain: String? = null,
authHeaderSigner: AuthHeaderSigner? = null
): String? {
if (authHeaderSigner != null) {
// For noncustodial issuer is unknown -> comes from SEP-1 toml file
val issuer = if (clientDomain == null) account else null
val claims = parameters.toMutableMap()
claims["web_auth_endpoint"] = webAuthEndpoint
return authHeaderSigner.createToken(claims, clientDomain, issuer)
}
return null
}
45 changes: 43 additions & 2 deletions wallet-sdk/src/main/kotlin/org/stellar/walletsdk/util/Util.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,28 @@ import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.Security
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.time.Duration
import java.time.Instant
import kotlinx.serialization.SerializationException
import org.bouncycastle.asn1.DEROctetString
import org.bouncycastle.asn1.edec.EdECObjectIdentifiers
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo
import org.bouncycastle.asn1.x509.AlgorithmIdentifier
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.stellar.sdk.StrKey
import org.stellar.sdk.TimeBounds
import org.stellar.walletsdk.anchor.AnchorTransaction
import org.stellar.walletsdk.anchor.TransactionStatus
import org.stellar.walletsdk.asset.*
import org.stellar.walletsdk.asset.FIAT_SCHEME
import org.stellar.walletsdk.asset.STELLAR_SCHEME
import org.stellar.walletsdk.auth.AuthToken
import org.stellar.walletsdk.exception.*
import org.stellar.walletsdk.horizon.SigningKeyPair
import org.stellar.walletsdk.json.fromJson
import org.stellar.walletsdk.toml.TomlInfo

Expand All @@ -40,6 +51,13 @@ internal object Util {
internal suspend inline fun <reified T> HttpClient.authGet(
url: String,
authToken: AuthToken? = null,
): T {
return this.authGetStringToken(url, authToken.toString())
}

internal suspend inline fun <reified T> HttpClient.authGetStringToken(
url: String,
authToken: String? = null,
): T {
val textBody =
this.get(url) {
Expand All @@ -49,6 +67,8 @@ internal object Util {
}
.bodyAsText()

println(textBody)

return textBody.fromJsonOrError()
}

Expand Down Expand Up @@ -150,3 +170,24 @@ fun String.toAssetId(): AssetId {
}
throw InvalidJsonException("Unknown scheme", str)
}

fun SigningKeyPair.toJava(): java.security.KeyPair {
Security.addProvider(BouncyCastleProvider())

val factory: KeyFactory = KeyFactory.getInstance("Ed25519")
val privKeyInfo =
PrivateKeyInfo(
AlgorithmIdentifier(EdECObjectIdentifiers.id_Ed25519),
DEROctetString(StrKey.decodeEd25519SecretSeed(this.keyPair.secretSeed))
)
val pkcs8KeySpec = PKCS8EncodedKeySpec(privKeyInfo.getEncoded())

val pubKeyInfo =
SubjectPublicKeyInfo(AlgorithmIdentifier(EdECObjectIdentifiers.id_Ed25519), this.publicKey)
val x509KeySpec = X509EncodedKeySpec(pubKeyInfo.getEncoded())
val jcaPublicKey = factory.generatePublic(x509KeySpec)

val private: PrivateKey = factory.generatePrivate(pkcs8KeySpec)

return java.security.KeyPair(jcaPublicKey, private)
}
Loading

0 comments on commit 592f5e6

Please sign in to comment.