Search code examples
kotlinjwtktor

Using Ktor with JWT RS256 for authentication leads to illegal base64 char exception


I am trying to implement authentication in my Ktor server using JWT, and specifically, using RS256 signing.

I was following the official documentation and the sample project but I've stumbled upon an 500: java.lang.IllegalArgumentException: Illegal base64 character 2b error that I cannot figure out.

Here is my HOCON config with a sample private key:

ktor {
  development = true
  deployment {
    port = 8080
    port = ${?PORT}
    autoreload = true
    watch = [backend]
  }
  application {
    modules = [app.company.ApplicationKt.module]
  }
}

jwt {
  privateKey = "VFVsSlJreFVRbGhDWjJ0eGFHdHBSemwzTUVKQ1VUQjNVMnBCY0VKbmEzRm9hMmxIT1hjd1FrSlJkM2RJUVZGSmExTXdZMWR5ZVVkcldtTkRRV2RuUVUxQmQwZERRM0ZIVTBsaU0wUlJTVXBDVVVGM1NGRlpTbGxKV2tsQlYxVkVRa0ZGY1VKQ1FpOW9UMDVPYzBwaE9GY3ZNMFUwV2xadkwwMDVaRUpKU1VVZ01FRTFUa2RGU2pkc1lYQk9kSFozUkM5TVNrSklRV3BzVkRGblRXeEZlWEZVZG1GcVREYzFUM2xaY2lzdmIwZGpWbmRVY0djcldVbHdZekZuV2pZeWR5QmxRMjR2TlZGaFFua3pRa2RHWkVOVVFsZENWSGhPUmtacFNUbFNZMGxuUVVoVFZYVlpUWGxOT1V3MGR6Uk1Va00wUW1ObFNHRklWMHRFZEcweWMyZDJJRmRUVDNaVlNETkZVM2xFWm1kbGEzUnVSVGM0TldkdVpWWXpjRWhPY0RWVWFYbzVVblIyVTNkaFJXcHNkR2N3TWxSSVdFZFVSVzlsWTI4eVIwMUVjQ3NnVDJWVGJsWkJRM1pFTUZremRIVXhaak00UkVsT1pFOVZWamxrY0Znek0zUkxUSE53Tm1GeGMyOTFUVVZFVDBSM1JTc3lTVGxRYmtoNGNXbENXVXc0YUNCbVZWa3lXRlZUUVdnMVNDczVWMnh5UlUwM2QwTmlOekV6VEdveGJXRm9SSGxIVmxGd05sZ3pNWGRWVlc1WFZsQlJUa2N4TURNNFUySXlibkJqY2xrM0lFRTBkRlpUUzJZeFZYbHhNMUJUWmxOV2VXZE9ObFpJYVdzelN6aFJXSGxoS3poUVRXTkVRVXRyY3pjeVZWWk1ja2xHWXpGV0wyVTViREJXV2xNd1IyUWdjbGh6TVRKblFXNXJZMk5XUmpBMVVuWTFjRUZGUnpOb1pXMHZUVEl5WTFOME5HOVpOVU5FUkhOR2VuTnNOM1ZtWkhaTmJsYzVXV0ZTZDJ0VlRITjFZaUJKY0M4dlZuZFlNVkJsZFhGT1ZEVXlNM2N6UlRsUlowUnBhazV0WVZOek1FaDRWM3A0U0ZWdGFETjViVlJGTDFVMFNrMTRaa1p0VW1aTE9FTmFZMVZrSUVRelNUaGhTbFZ5UXpJM2NUZHRjbXRWUkd4Mk0wcDJjVUpzVUZReVpsWXlhQzl6VFd4b01sSjVNRTh3TkdadlFXRmpaMnRPTldkVldFRXlTblJZVjFJZ2RXRXdOVmg1Y1dGM2FDOHpPVVZMWVROcVYwRnJkakZHUldvdmJqQlVRWHBRYUhGb2JFaHllamhyY0hKRGExTTJRMDlpZVZsTkwzbE9ZMEZpVG5ob1ppQnhaMUV5Y1ZOSmFpOTRabEE1V0hKTFdHOXFkRTVIVHpOTGNIaEVMMEZTVkhwT1VXVnhPVUk1TjFscmJrZG5aVXhLYlhjeWRsRkVVM0ZSY1hRM2FVeDRJSFp0Y2tSc1dHTjBSWFpzYzJ4TGRUaEhUVmgzVEhVeVVXTXlNamxGY1hWelIydFVNSGxDTlVoMGRVbFFXVFF6YWxVdmRFTXZVM2c1YVhBdlVFc3JhVTRnY0hOUVZFMU9jbWxSU3pWbmRrdFNRMjVLT0hWMFZteDJiR0p2WWtWNFJqVkNRa3A2UWxObk4yMU1lRWgxVVhCc1VVaDVNV0l3WlUxb2MxVlpVemxoV0NCa2VFNXNVbTUzVVhOalJFZGxUR2hSVUhOUmNqQkxOREJWTVM5NU1USkZiemx5WlhoRFp5dHRVMmxsTDNOMWMzTjNUM0pTYm1WR2NXOXpVSFpHVDBkWUlFMTFTV0p5VFZSRVJuSnFPVGRLZWtwWFJERk9SR0ZpZFdoc056RkhTSFp1Wkdob2RIUjZVMDlsV0dkWVlsTjVRVkV6ZVdneFUxZDBSemh6WWtVeEwyRWdjMmRZTkVkTlpGSkxNMVZFWVhCNWFUSlVSVlJIVFhKTU1VWlJTbXBUV1VONWJ6QjBhVXBtV1c5VGEyaFFWU3R4YlZObmNVWlBkMWRCVG1SRVFqaHNZaUE1WkhsaFMwUlpPVzVZYW1aRGJqVXJTWEZJTTNoVmFTdFFRMU5NY0hKQlNVRjRaM1ZQWTJ4c04xZFBZbFkxWW5aelQyRkJRaTluUzJSd2JuTnpkVUo2SUM4eWVIRnlTM1ZMWlVrd1lqTk9Xa05UUVdoeE0wcGlaMHQ0TkdZM0wxWlJhR0pKV1hkVEszUkhjVXRMTjBoeE5UVlVlSHBaYXpWUVJrdFJZVTFqZWtvZ1JUZERUWEY0VFcxYVNtNDJTVWhxWXpKS1l6aE1ObEJUVFM5d2NIcHFhbEV2VjJKVU9HaEpPVFpOUkdsek1HMHlWMkk1ZEM5WlYxVk9aSFZ0YW1oaU5DQjRja0Z3VFV0T1ZYSTVkek0zZGs1SVR6TlJUSEJtTlRBdmIwMHpkMnBEZWpSRFVYSjNXREZaTDBwdlJHSk5kRnBrYjFaNE9HcHJSRFZJSzJORVNuRklJRUV3WWpKSGQwWjVhR05aYUd4RFF6YzRTSEZOZUVOYVYxcHdVVlEzWjBoR1pYRllSVU5yYUhJNWQwZGtaVTl1TWpSMmVEUlZMMmxQV1RkMlJYaGhiV2NnU0VKeUt6Tm1iM1JMYjFoRVdsRnFiREpZU1hSTVFuSkdXV0ZhTW1SSVNUbGlNRXhSY1V4a2JIVjBSRlV5UzB4SU1tUlRWVkJMVTNjNGFWcEtlbWxhZHlCQ1JESnpRV00yYkZCRUx6TkdVRVkxYUdGVEt6QTFOVVpCVEVaeVQzcDVUV2wwYUZGdVdrSXpTall6TDBoSU1USlJVa3BUUjNWT2JtVmxNRVpOTTNsTElDdDVSekJVWjNVeVJtbHROSEZOTjJwT1ZYUXdSSFozVkZoc2RFc3hhR0pYTVVsM1ZIZG9hVkkxVTBwWE5rNUJVbkZLVlZOeWVuTmxWWFpSTW1Rek1ra2dTa0ZVVTBKVGJUTnJiWGN2TVZWa1NETXdOV2hwS3pSdGR6RnJSblZCYlU5NFpXTnNjMnR6ZGxCS2IzSXJSR1ZEYTJWTUsyNTRPVTl3YzNCb1drRXhaeUF3VUVOQmVVUjJSMDk1VXpCUVFXUlNOVUZFVkZjNGF6UXphSGxCVHpGb2NIWk1iMlp0VWtKYVowRm1TUQ"
  issuer = "http://0.0.0.0:8080/"
  audience = "http://0.0.0.0:8080/login"
  realm = "Company Login"
}

postgres {
  url = ${pg_url}
  user = ${pg_username}
  password = ${pg_password}
}

my configure security module:

fun Application.configureSecurity() {
    val jwtAudience = environment.config.property("jwt.audience").getString()
    val jwtIssuer = environment.config.property("jwt.issuer").getString()
    val jwtRealm = environment.config.property("jwt.realm").getString()
    val jwtPK = environment.config.property("jwt.privateKey").getString()
    val jwkProvider = JwkProviderBuilder(jwtIssuer)
        .cached(10, 24, TimeUnit.HOURS)
        .rateLimited(10, 1, TimeUnit.MINUTES)
        .build()
    install(Authentication) {
        jwt("auth-jwt") {
            realm = jwtRealm
            verifier(jwkProvider, jwtIssuer) {
                acceptLeeway(3)
            }
            validate { credential ->
                if (credential.payload.getClaim("username").asString() != "") {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }
            challenge { _, _ ->
                call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
            }
        }
    }

    routing {
        post("/login") {

            val userCredentials = call.receive<UserLoginCredentials>()

            //todo check credentials
            if (userCredentials.username != "mike" || userCredentials.password != "shh") {
                call.respond(HttpStatusCode.Unauthorized, "Credentials do not match.")
                return@post
            }

            val publicKey = jwkProvider.get("6f8856ed-9189-488f-9011-0ff4b6c08edc").publicKey
            val keySpecPKCS8 = PKCS8EncodedKeySpec(Base64.getDecoder().decode(jwtPK))
            val privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpecPKCS8)
            val token = JWT.create()
                .withAudience(jwtAudience)
                .withIssuer(jwtIssuer)
                .withClaim("username", userCredentials.username)
                .withExpiresAt(Date(System.currentTimeMillis() + 60000))
                .sign(Algorithm.RSA256(publicKey as RSAPublicKey, privateKey as RSAPrivateKey))
            call.respond(hashMapOf("token" to token))
        }
        authenticate("auth-jwt") {
            get("/hello") {
                val principal = call.principal<JWTPrincipal>()
                val username = principal!!.payload.getClaim("username").asString()
                val expiresAt = principal.expiresAt?.time?.minus(System.currentTimeMillis())
                call.respondText("Hello, $username! Token is expired at $expiresAt ms.")
            }
        }
        staticFiles(".well-known/jwks.json", File("certs/jwks.json"))
    }
}

And finally, the certs/jwks.json:

{
  "keys": [
    {
      "kty": "RSA",
      "e": "65537",
      "kid": "6f8856ed-9189-488f-9011-0ff4b6c08edc",
      "n":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuYAHLNnQXSGiKS2MY4u14Pzi3Ckk2FOwghFvcE2EP9Br7pTJ4j5HDYQ7vT+LHj6N2chnhGvTxZrj2+ty5fakPyGXNsC5UEMEUfTcyWe1dN2JidRLLBsUV3CvlmGdPPVLODrQQm7TMVA5x2Rwq9w9OiuD9KMF79Z87T6l7JfM9lXG+TM+JjR1pD25bMm8pCzb5+VKXEsBYwgXMPVIZ4mSm/07daiXmsfj7XDNP6U1B5xltZSdb3ZFiNYLrZNZHvsO+Q2shma9UwqRLqr/Fqx4oNggbLLqut4BujLj/Gq76E4LgwYUqdx7hVOMjZGt9E5tyFMWPRJ1SyK37zXeYxSRcwIDAQAB"
    }
  ]
}

The key pair was generated using openssl genpkey -algorithm RSA -out private_key.pem -aes256. When I make a POST request to the /login endpoint (sending the "mike", "shh" payload), I get the illegal char error. Debugging the server seems to point to the val publicKey = jwkProvider.get("6f8856ed-9189-488f-9011-0ff4b6c08edc").publicKey which I don't get. What am I missing?

Update: Alright, I have generated new key-pairs using ssh-keygen -t rsa -C "email", then converted them to pem files using ssh-keygen -f ktor.pub -e -m pem > ktor.pub.pem for the public key and ssh-keygen -p -m PEM -f ktor for the private one. I then removed any new lines and converted them to the Base64URL encoding. I also encoded the exponent ("e") using openssl rsa -pubin -in ktor.pub.pem -text -noout | grep "Exponent" | awk '{prin t $2}' and updated the fields in the project. Now, when I debug the call, the exception seems to be 500: java.security.spec.InvalidKeySpecException: java.security.InvalidKeyException: invalid key format caused by this function call val publicKey = jwkProvider.get("6f8856ed-9189-488f-9011-0ff4b6c08edc").publicKey, specifically the publicKey = kf.generatePublic(new RSAPublicKeySpec(modulus, exponent)); in:

public PublicKey getPublicKey() throws InvalidPublicKeyException {
    PublicKey publicKey = null;

    switch (type) {
        case ALGORITHM_RSA:
            try {
                KeyFactory kf = KeyFactory.getInstance(ALGORITHM_RSA);
                BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(stringValue("n")));
                BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(stringValue("e")));
                publicKey = kf.generatePublic(new RSAPublicKeySpec(modulus, exponent));
            } catch (InvalidKeySpecException e) {
                throw new InvalidPublicKeyException("Invalid public key", e);
            } catch (NoSuchAlgorithmException e) {
                throw new InvalidPublicKeyException("Invalid algorithm to generate key", e);
            }
            break;

Solution

  • Preliminarily, the value you posted for privatekey is clearly fake (which is fine, you don't want to publicize a real privatekey), but the result of ssh-keygen (mod) -mPEM is NOT PKCS8 and not correct for use in standard Java crypto i.e. PKCS8EncodedKeySpec. OTOH the result of ssh-keygen (mod) -mPKCS8 (with empty password) is, and so is the output from openssl genpkey with no cipher or in 3.0 up openssl genrsa ditto, or openssl pkey with no cipher and input in traditional or PKCS8 format.

    JWK for RSA publickey must have e the exponent in base64url (not in decimal as you have) and n the modulus in base64url not the entire X.509/PKIX SPKI structure in base64 as you have (apparently from an OpenSSL-format publickey file, which OpenSSH ssh-keygen weirdly calls -mPKCS8 NOT -mPEM).

    The example code you reference (I link the current version instead, but it doesn't appear to have changed) has the publickey JWK in https://github.com/ktorio/ktor-documentation/blob/2.3.7/codeSnippets/snippets/auth-jwt-rs256/certs/jwks.json corresponding to the privatekey in https://github.com/ktorio/ktor-documentation/blob/2.3.7/codeSnippets/snippets/auth-jwt-rs256/src/main/resources/application.conf -- which is in base64 PKCS8-clear, thus usable after decoding in Java crypto PKCS8EncodedKeySpec. But AFAICS none of the relevant ktor documentation tells you how to create this. If you have OpenSSL on Unix (including almost-Unix like WSL or git4win) you can do

    $ echo >77770123.pk8 MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAtfJaLrzXILUg1U3N1KV8yJr92GHn5OtYZR7qWk1Mc4cy4JGjklYup7weMjBD9f3bBVoIsiUVX6xNcYIr0Ie0AQIDAQABAkEAg+FBquToDeYcAWBe1EaLVyC45HG60zwfG1S4S3IB+y4INz1FHuZppDjBh09jptQNd+kSMlG1LkAc/3znKTPJ7QIhANpyB0OfTK44lpH4ScJmCxjZV52mIrQcmnS3QzkxWQCDAiEA1Tn7qyoh+0rOO/9vJHP8U/beo51SiQMw0880a1UaiisCIQDNwY46EbhGeiLJR1cidr+JHl86rRwPDsolmeEF5AdzRQIgK3KXL3d0WSoS//K6iOkBX3KMRzaFXNnDl0U/XyeGMuUCIHaXv+n+Brz5BDnRbWS+2vkgIe9bUNlkiArpjWvX+2we
    $ # this is the value from the ktor example; alternatively use body part from
    $ # openssl genpkey -algorithm rsa or ssh-keygen (gen/mod) -mPKCS8 
    $ base64 -d <77770123.pk8 | openssl pkey -inform der -pubout | tee 77770123.spki
    -----BEGIN PUBLIC KEY-----
    MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALXyWi681yC1INVNzdSlfMia/dhh5+Tr
    WGUe6lpNTHOHMuCRo5JWLqe8HjIwQ/X92wVaCLIlFV+sTXGCK9CHtAECAwEAAQ==
    -----END PUBLIC KEY-----
    $ # or substitute here the file from `ssh-keygen -e -mPKCS8` for your key
    
    $ openssl pkey -in 77770123.spki -pubin -noout -text
    RSA Public-Key: (512 bit)
    Modulus:
        00:b5:f2:5a:2e:bc:d7:20:b5:20:d5:4d:cd:d4:a5:
        7c:c8:9a:fd:d8:61:e7:e4:eb:58:65:1e:ea:5a:4d:
        4c:73:87:32:e0:91:a3:92:56:2e:a7:bc:1e:32:30:
        43:f5:fd:db:05:5a:08:b2:25:15:5f:ac:4d:71:82:
        2b:d0:87:b4:01
    Exponent: 65537 (0x10001)
    $ # 512-bit! that's totally insecure. Use that ONLY as an example!
    
    $ echo 010001 | xxd -p -r | base64 # note even number of hex digits
    AQAB
    $ # this particular base64 is already base64url and doesn't need tr
    $ # in practice nowadays everything uses e=F4 (decimal 65537, hex 010001, base64 AQAB) 
    $ # so you _could_ hardcode this, but I show the proper method anyway
    
    $ echo "b5:f2:5a:2e:bc:d7:20:b5:20:d5:4d:cd:d4:a5:
        7c:c8:9a:fd:d8:61:e7:e4:eb:58:65:1e:ea:5a:4d:
        4c:73:87:32:e0:91:a3:92:56:2e:a7:bc:1e:32:30:
        43:f5:fd:db:05:5a:08:b2:25:15:5f:ac:4d:71:82:
        2b:d0:87:b4:01" | tr -d ": \n" | xxd -p -r | base64 | tr +/ -_ | tr -d "=\n"
    tfJaLrzXILUg1U3N1KV8yJr92GHn5OtYZR7qWk1Mc4cy4JGjklYup7weMjBD9f3bBVoIsiUVX6xNcYIr0Ie0AQ
    $ # note leading 00 byte omitted; that's an artifact of the ASN.1 encoding