diff --git a/src/main/java/com/spotify/github/v3/clients/PKCS1PEMKey.java b/src/main/java/com/spotify/github/v3/clients/PKCS1PEMKey.java index e3f490b1..d3d27801 100644 --- a/src/main/java/com/spotify/github/v3/clients/PKCS1PEMKey.java +++ b/src/main/java/com/spotify/github/v3/clients/PKCS1PEMKey.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -32,26 +32,68 @@ */ final class PKCS1PEMKey { + // Matches RSA PEM format private static final Pattern PKCS1_PEM_KEY_PATTERN = - Pattern.compile("(?m)(?s)^---*BEGIN RSA PRIVATE KEY.*---*$(.*)^---*END.*---*$.*"); + Pattern.compile("(?m)(?s)^\\s*-{3,}\\s*BEGIN\\s+RSA\\s+PRIVATE\\s+KEY\\s*-{3,}\\s*$(.*)^\\s*-{3,}\\s*END\\s+RSA\\s+PRIVATE\\s+KEY\\s*-{3,}\\s*$.*"); + + // Matches PKCS8 PEM format + private static final Pattern PKCS8_PEM_KEY_PATTERN = + Pattern.compile("(?m)(?s)^\\s*-{3,}\\s*BEGIN\\s+PRIVATE\\s+KEY\\s*-{3,}\\s*$(.*)^\\s*-{3,}\\s*END\\s+PRIVATE\\s+KEY\\s*-{3,}\\s*$.*"); private PKCS1PEMKey() {} /** - * Try to interpret the supplied key as a PKCS#1 PEM file. + * Try to interpret the supplied key as a PEM file (PKCS#1 or PKCS#8). * * @param privateKey the private key to use */ public static Optional loadKeySpec(final byte[] privateKey) { - final Matcher isPEM = PKCS1_PEM_KEY_PATTERN.matcher(new String(privateKey)); - if (!isPEM.matches()) { - return Optional.empty(); + final String keyString = new String(privateKey); + + // Try to match PKCS1 (RSA) format first + Matcher isPKCS1 = PKCS1_PEM_KEY_PATTERN.matcher(keyString); + if (isPKCS1.matches()) { + return extractKeySpec(isPKCS1.group(1), true); + } + + // Try to match PKCS8 format + Matcher isPKCS8 = PKCS8_PEM_KEY_PATTERN.matcher(keyString); + if (isPKCS8.matches()) { + return extractKeySpec(isPKCS8.group(1), false); } - byte[] pkcs1Key = Base64.getMimeDecoder().decode(isPEM.group(1)); - byte[] pkcs8Key = toPkcs8(pkcs1Key); - final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8Key); - return Optional.of(keySpec); + // Not a recognized PEM format + return Optional.empty(); + } + + /** + * Extract a KeySpec from the base64 content. + * + * @param base64Content the base64 content from the PEM file + * @param isPKCS1 whether this is PKCS1 format (needs conversion to PKCS8) + * @return an Optional containing the KeySpec if successful + */ + private static Optional extractKeySpec(final String base64Content, final boolean isPKCS1) { + try { + // Remove all whitespace + String sanitizedContent = base64Content.replaceAll("\\s+", ""); + + // Check if content is empty after whitespace removal + if (sanitizedContent.isEmpty()) { + return Optional.empty(); + } + + // Decode the base64 content + byte[] decodedKey = Base64.getDecoder().decode(sanitizedContent); + + // Convert to PKCS8 if necessary + byte[] pkcs8Key = isPKCS1 ? toPkcs8(decodedKey) : decodedKey; + + return Optional.of(new PKCS8EncodedKeySpec(pkcs8Key)); + } catch (IllegalArgumentException e) { + // Failed to decode base64 content + return Optional.empty(); + } } /** @@ -68,10 +110,10 @@ private static byte[] toPkcs8(final byte[] pkcs1Bytes) { final int pkcs1Length = pkcs1Bytes.length; final int totalLength = pkcs1Length + 22; byte[] pkcs8Header = new byte[] { - 0x30, (byte) 0x82, (byte) ((totalLength >> 8) & 0xff), (byte) (totalLength & 0xff), // Sequence + total length - 0x2, 0x1, 0x0, // Integer (0) - 0x30, 0xD, 0x6, 0x9, 0x2A, (byte) 0x86, 0x48, (byte) 0x86, (byte) 0xF7, 0xD, 0x1, 0x1, 0x1, 0x5, 0x0, // Sequence: 1.2.840.113549.1.1.1, NULL - 0x4, (byte) 0x82, (byte) ((pkcs1Length >> 8) & 0xff), (byte) (pkcs1Length & 0xff) // Octet string + length + 0x30, (byte) 0x82, (byte) ((totalLength >> 8) & 0xff), (byte) (totalLength & 0xff), // Sequence + total length + 0x2, 0x1, 0x0, // Integer (0) + 0x30, 0xD, 0x6, 0x9, 0x2A, (byte) 0x86, 0x48, (byte) 0x86, (byte) 0xF7, 0xD, 0x1, 0x1, 0x1, 0x5, 0x0, // Sequence: 1.2.840.113549.1.1.1, NULL + 0x4, (byte) 0x82, (byte) ((pkcs1Length >> 8) & 0xff), (byte) (pkcs1Length & 0xff) // Octet string + length }; byte[] pkcs8bytes = new byte[pkcs8Header.length + pkcs1Bytes.length]; diff --git a/src/test/java/com/spotify/github/v3/clients/GitHubClientPEMTest.java b/src/test/java/com/spotify/github/v3/clients/GitHubClientPEMTest.java new file mode 100644 index 00000000..16d46ee6 --- /dev/null +++ b/src/test/java/com/spotify/github/v3/clients/GitHubClientPEMTest.java @@ -0,0 +1,110 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2016 - 2025 Spotify AB + * -- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * -/-/- + */ + +package com.spotify.github.v3.clients; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.google.common.io.Resources; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * Tests for GitHub with various PEM key formats + */ +public class GitHubClientPEMTest { + + private static final Pattern BEGIN_PATTERN = Pattern.compile("(-----)BEGIN RSA PRIVATE KEY(-----)"); + private static final Pattern END_PATTERN = Pattern.compile("(-----)END RSA PRIVATE KEY(-----)"); + + @Test + public void testJwtWithPoorlyFormattedPEM() throws Exception { + // Load a PEM key resource + byte[] originalKey = Resources.toByteArray( + Resources.getResource("com/spotify/github/v3/fake-github-app-key.pem")); + String pemContent = new String(originalKey, StandardCharsets.UTF_8); + + // Manipulate just the BEGIN/END delimiters without touching the key content + String poorlyFormattedPem = pemContent; + + // Add spaces to BEGIN + Matcher beginMatcher = BEGIN_PATTERN.matcher(poorlyFormattedPem); + if (beginMatcher.find()) { + poorlyFormattedPem = poorlyFormattedPem.replace( + beginMatcher.group(0), + beginMatcher.group(1) + " BEGIN RSA PRIVATE KEY " + beginMatcher.group(2) + ); + } + + // Add spaces to END + Matcher endMatcher = END_PATTERN.matcher(poorlyFormattedPem); + if (endMatcher.find()) { + poorlyFormattedPem = poorlyFormattedPem.replace( + endMatcher.group(0), + endMatcher.group(1) + " END RSA PRIVATE KEY " + endMatcher.group(2) + ); + } + + // Test that we can parse this PEM + JwtTokenIssuer tokenIssuer = JwtTokenIssuer.fromPrivateKey( + poorlyFormattedPem.getBytes(StandardCharsets.UTF_8)); + String token = tokenIssuer.getToken(123456); + assertNotNull(token); + } + + @Test + public void testJwtWithExtraDashesPEM() throws Exception { + // Load a PEM key resource + byte[] originalKey = Resources.toByteArray( + Resources.getResource("com/spotify/github/v3/fake-github-app-key.pem")); + String pemContent = new String(originalKey, StandardCharsets.UTF_8); + + // Manipulate just the BEGIN/END delimiters without touching the key content + String extraDashesPem = pemContent; + + // Add extra dashes to BEGIN + Matcher beginMatcher = BEGIN_PATTERN.matcher(extraDashesPem); + if (beginMatcher.find()) { + extraDashesPem = extraDashesPem.replace( + beginMatcher.group(0), + "-------BEGIN RSA PRIVATE KEY-------" + ); + } + + // Add extra dashes to END + Matcher endMatcher = END_PATTERN.matcher(extraDashesPem); + if (endMatcher.find()) { + extraDashesPem = extraDashesPem.replace( + endMatcher.group(0), + "-------END RSA PRIVATE KEY-------" + ); + } + + // Test that we can parse this PEM + JwtTokenIssuer tokenIssuer = JwtTokenIssuer.fromPrivateKey( + extraDashesPem.getBytes(StandardCharsets.UTF_8)); + String token = tokenIssuer.getToken(123456); + assertNotNull(token); + } +} \ No newline at end of file diff --git a/src/test/java/com/spotify/github/v3/clients/JwtTokenIssuerTest.java b/src/test/java/com/spotify/github/v3/clients/JwtTokenIssuerTest.java index 4bb1622d..ef5ee980 100644 --- a/src/test/java/com/spotify/github/v3/clients/JwtTokenIssuerTest.java +++ b/src/test/java/com/spotify/github/v3/clients/JwtTokenIssuerTest.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -20,30 +20,31 @@ package com.spotify.github.v3.clients; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; import com.google.common.io.Resources; import java.net.URL; import org.junit.jupiter.api.Test; +/** + * Simple test for JwtTokenIssuer to focus on core functionality + */ public class JwtTokenIssuerTest { private static final URL DER_KEY_RESOURCE = - Resources.getResource("com/spotify/github/v3/github-private-key"); + Resources.getResource("com/spotify/github/v3/github-private-key"); - // generated using this command: "openssl genrsa -out fake-github-app-key.pem 2048" private static final URL PEM_KEY_RESOURCE = - Resources.getResource("com/spotify/github/v3/fake-github-app-key.pem"); + Resources.getResource("com/spotify/github/v3/fake-github-app-key.pem"); @Test - public void loadsDERFileWithPKCS8Key() throws Exception { + public void loadsDERFile() throws Exception { final byte[] key = Resources.toByteArray(DER_KEY_RESOURCE); final JwtTokenIssuer tokenIssuer = JwtTokenIssuer.fromPrivateKey(key); final String token = tokenIssuer.getToken(42); - assertThat(token, not(nullValue())); + assertNotNull(token); + System.out.println("DER key JWT token generated successfully: " + token); } @Test @@ -52,7 +53,7 @@ public void loadsPEMFile() throws Exception { final JwtTokenIssuer tokenIssuer = JwtTokenIssuer.fromPrivateKey(key); final String token = tokenIssuer.getToken(42); - assertThat(token, not(nullValue())); + assertNotNull(token); + System.out.println("PEM key JWT token generated successfully: " + token); } - -} +} \ No newline at end of file diff --git a/src/test/java/com/spotify/github/v3/clients/PKCS1PEMKeyTest.java b/src/test/java/com/spotify/github/v3/clients/PKCS1PEMKeyTest.java new file mode 100644 index 00000000..50a53492 --- /dev/null +++ b/src/test/java/com/spotify/github/v3/clients/PKCS1PEMKeyTest.java @@ -0,0 +1,114 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2016 - 2025 Spotify AB + * -- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * -/-/- + */ + +package com.spotify.github.v3.clients; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.security.spec.KeySpec; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +public class PKCS1PEMKeyTest { + + @Test + public void testStandardPEMFormat() { + String key = "-----BEGIN RSA PRIVATE KEY-----\n" + + "MIIEpAIBAAKCAQEA0Gjl7EWZZez7\n" + + "VnrMsLr8P0SQJ1gPzuiTnrMsLrbn\n" + + "-----END RSA PRIVATE KEY-----"; + + Optional result = PKCS1PEMKey.loadKeySpec(key.getBytes()); + assertTrue(result.isPresent()); + } + + @Test + public void testPEMWithExtraWhitespace() { + String key = " -----BEGIN RSA PRIVATE KEY----- \n" + + "MIIEpAIBAAKCAQEA0Gjl7EWZZez7\n " + + " VnrMsLr8P0SQJ1gPzuiTnrMsLrbn \n" + + " -----END RSA PRIVATE KEY----- "; + + Optional result = PKCS1PEMKey.loadKeySpec(key.getBytes()); + assertTrue(result.isPresent()); + } + + @Test + public void testPEMWithManyDashes() { + String key = "--------BEGIN RSA PRIVATE KEY--------\n" + + "MIIEpAIBAAKCAQEA0Gjl7EWZZez7\n" + + "VnrMsLr8P0SQJ1gPzuiTnrMsLrbn\n" + + "--------END RSA PRIVATE KEY--------"; + + Optional result = PKCS1PEMKey.loadKeySpec(key.getBytes()); + assertTrue(result.isPresent()); + } + + @Test + public void testPKCS8Format() { + String key = "-----BEGIN PRIVATE KEY-----\n" + + "MIIEpAIBAAKCAQEA0Gjl7EWZZez7\n" + + "VnrMsLr8P0SQJ1gPzuiTnrMsLrbn\n" + + "-----END PRIVATE KEY-----"; + + Optional result = PKCS1PEMKey.loadKeySpec(key.getBytes()); + assertTrue(result.isPresent()); + } + + @Test + public void testMultilinePEMFormat() { + String key = "-----BEGIN RSA PRIVATE KEY-----\r\n" + + "MIIEpAIBAAKCAQEA0Gjl7EWZZez7\r\n" + + "VnrMsLr8P0SQJ1gPzuiTnrMsLrbn\r\n" + + "-----END RSA PRIVATE KEY-----"; + + Optional result = PKCS1PEMKey.loadKeySpec(key.getBytes()); + assertTrue(result.isPresent()); + } + + @Test + public void testInvalidBase64Content() { + String key = "-----BEGIN RSA PRIVATE KEY-----\n" + + "This is not valid base64 content!\n" + + "-----END RSA PRIVATE KEY-----"; + + Optional result = PKCS1PEMKey.loadKeySpec(key.getBytes()); + assertFalse(result.isPresent()); + } + + @Test + public void testEmptyContent() { + String key = "-----BEGIN RSA PRIVATE KEY-----\n" + + "\n" + + "-----END RSA PRIVATE KEY-----"; + + Optional result = PKCS1PEMKey.loadKeySpec(key.getBytes()); + assertFalse(result.isPresent()); + } + + @Test + public void testNonPEMFormat() { + String key = "This is not a PEM file at all."; + + Optional result = PKCS1PEMKey.loadKeySpec(key.getBytes()); + assertFalse(result.isPresent()); + } +} \ No newline at end of file