This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit acdf99a41ed43d0bd05dea9bd469b48c227500e1 Author: Amichai Rothman <amich...@amichais.net> AuthorDate: Wed Jun 25 19:39:02 2025 +0300 [ENHANCEMENT] implement DefaultPublicKeyProvider kid calculation using the JWK Thumbprint of the key (RFC 7638) --- .../apache/james/jwt/DefaultPublicKeyProvider.java | 70 ++++++++++++++++++++-- .../james/jwt/DefaultPublicKeyProviderTest.java | 36 ++++++++++- src/site/xdoc/server/config-webadmin.xml | 4 +- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java index 8627b1526b..0085645f6f 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java @@ -18,7 +18,14 @@ ****************************************************************/ package org.apache.james.jwt; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.PublicKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.EdECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; import java.util.List; import java.util.Optional; @@ -48,10 +55,65 @@ public class DefaultPublicKeyProvider implements PublicKeyProvider { @Override public Optional<PublicKey> get(String kid) throws MissingOrInvalidKeyException { - // TODO: pick a simple or standard way of calculating a unique kid for each public key - // that can be calculated by the user when generating the JWT - // and calculated or looked up here for comparison. - return Optional.empty(); + return get().stream().filter(k -> computeKid(k).equals(kid)).findFirst(); } + protected static byte[] unpad(byte[] bytes) { + return (bytes.length > 1 && bytes[0] == 0x00 && (bytes[1] & 0x80) != 0) + ? java.util.Arrays.copyOfRange(bytes, 1, bytes.length) + : bytes; + } + + protected static String b64u(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + protected static String toCanonicalJwkJson(PublicKey key) { + // json, only required keys, lexicographically sorted keys, + // no whitespace, UTF8, base64url encoded unmodified raw values + if (key instanceof RSAPublicKey rsa) { + String n = b64u(unpad(rsa.getModulus().toByteArray())); + String e = b64u(unpad(rsa.getPublicExponent().toByteArray())); + return String.format("{\"e\":\"%s\",\"kty\":\"RSA\",\"n\":\"%s\"}", e, n); + } + + if (key instanceof ECPublicKey ec) { + String crv = "P-" + ec.getParams().getCurve().getField().getFieldSize(); + String x = b64u(unpad(ec.getW().getAffineX().toByteArray())); + String y = b64u(unpad(ec.getW().getAffineY().toByteArray())); + return String.format("{\"crv\":\"%s\",\"kty\":\"EC\",\"x\":\"%s\",\"y\":\"%s\"}", crv, x, y); + } + + // ED key bits are more complicated to get from BigInteger + // (reversing, adding x bit, adding/removing padding to exact size, + // but we don't know the key size itself form the java instance) + // so we do very basic ASN.1 parsing ourselves to find the + // position and length of the raw bits (which differ in different curves) + if (key instanceof EdECPublicKey ed) { + String crv = ed.getParams().getName(); + byte[] encoded = key.getEncoded(); // SubjectPublicKeyInfo structure, encoded using ASN.1 DER + int pos = 2; // skip outer SEQUENCE header + pos += 2 + encoded[pos + 1] + 1; // skip AlgorithmIdentifier SEQUENCE and BIT STRING tag (0x03) + int len = encoded[pos++]; // BIT STRING length (assume short form - less than 128 raw bytes) + pos++; // skip unused bits byte + byte[] raw = new byte[len - 1]; + System.arraycopy(encoded, pos, raw, 0, raw.length); + String x = b64u(raw); + return String.format("{\"crv\":\"%s\",\"kty\":\"OKP\",\"x\":\"%s\"}", crv, x); + } + + throw new IllegalArgumentException("Unsupported key algorithm: " + key.getAlgorithm()); + } + + public static String computeKid(PublicKey key) { + // calculate the JWK Thumbprint of the key (RFC 7638), + // which is base64url(sha256(canonicalJson(jwk(key)))) + String jwk = toCanonicalJwkJson(key); + byte[] jwkBytes = jwk.getBytes(StandardCharsets.UTF_8); + try { + return b64u(MessageDigest.getInstance("SHA-256").digest(jwkBytes)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } } diff --git a/server/protocols/jwt/src/test/java/org/apache/james/jwt/DefaultPublicKeyProviderTest.java b/server/protocols/jwt/src/test/java/org/apache/james/jwt/DefaultPublicKeyProviderTest.java index 4c59934cb3..13e743fb75 100644 --- a/server/protocols/jwt/src/test/java/org/apache/james/jwt/DefaultPublicKeyProviderTest.java +++ b/server/protocols/jwt/src/test/java/org/apache/james/jwt/DefaultPublicKeyProviderTest.java @@ -63,4 +63,38 @@ class DefaultPublicKeyProviderTest { assertThat(sut.get()).isEmpty(); } -} \ No newline at end of file + + private static void testKid(String exptected, String pem) { + JwtConfiguration configWithPEMKey = new JwtConfiguration(ImmutableList.of(pem)); + PublicKeyProvider sut = new DefaultPublicKeyProvider(configWithPEMKey, new PublicKeyReader()); + String kid = DefaultPublicKeyProvider.computeKid(sut.get().get(0)); + assertThat(kid).isEqualTo(exptected); + } + + @Test + void computeKidShouldComputeJWKThumbprintCorrectly() { + testKid("2iUdFiYTvwSzAzJuMRwvr70CLKKmYdbfDz0TpNQs0tc", PUBLIC_PEM_KEY); + + String pemRSA = "-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0vx7agoebGcQSuuPiLJX\n" + + "ZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tS\n" + + "oc/BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ/2W+5JsGY4Hc5n9yBXArwl93lqt\n" + + "7/RN5w6Cf0h4QyQ5v+65YGjQR0/FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0\n" + + "zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt+bFTWhAI4vMQFh6WeZu0f\n" + + "M4lFd2NcRwr3XPksINHaQ+G/xBniIqbw0Ls1jF44+csFCur+kEgU8awapJzKnqDK\n" + + "gwIDAQAB\n" + + "-----END PUBLIC KEY-----"; + testKid("NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", pemRSA); + + String pemEc = "-----BEGIN PUBLIC KEY-----\n" + + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7d1Se1rTIqVTXszA8gIHagLlqPH8\n" + + "a98VUCRHWaWW8S3J+WnwJsJVy/4qgZx6yFoJN7zAOIBcseO95zVrbet4gg==\n" + + "-----END PUBLIC KEY-----\n"; + testKid("WdX4yCEsy0Xx48ZfI_4DV0RPhFMdydNFqqwEqiAFAc8", pemEc); + + String pemEd = "-----BEGIN PUBLIC KEY-----\n" + + "MCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=\n" + + "-----END PUBLIC KEY-----"; + testKid("kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", pemEd); + } +} diff --git a/src/site/xdoc/server/config-webadmin.xml b/src/site/xdoc/server/config-webadmin.xml index 77b821c69c..5810a8c8c2 100644 --- a/src/site/xdoc/server/config-webadmin.xml +++ b/src/site/xdoc/server/config-webadmin.xml @@ -89,9 +89,11 @@ <ul> <li><strong>alg</strong> (header) - the signing algorithm, which must correspond to the key type, e.g. <code>RS256</code> for RSA or <code>ES256</code> for EC.</li> + <li><strong>kid</strong> (header, optional) - identifies the key used + to sign and verify the JWT. This is the JWK thumbprint of the key (as defined in RFC 7638).</li> <li><strong>sub</strong> - the address of the user, e.g. <code>ad...@example.com</code>.</li> <li><strong>admin</strong> - must be true (boolean literal, not string) to access admin operations.</li> - <li><strong>exp</strong> - the token expiration time, as the number of seconds (not milliseconds) + <li><strong>exp</strong> - (optional) the token expiration time, as the number of seconds (not milliseconds) since the epoch, a.k.a "unix time". If this claim is omitted, the token never expires.</li> </ul> For example, a token might have the header <code>{"alg":"ES256","typ":"JWT"}</code> --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org