Skip to content

Commit 8a91775

Browse files
authored
Support loading .pem private keys as issued by Github (#26)
* Construct JwtTokenIssuer through a factory method This makes it easier to use from unit tests, and also avoids throwing exceptions from the constructor. * Implement loading PKCS#1 PEM files as issued by Github * Extract PEM key handling into a utility class * Update README to use .pem private keys
1 parent 3cd5573 commit 8a91775

File tree

7 files changed

+200
-15
lines changed

7 files changed

+200
-15
lines changed

README.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
A small Java library for talking to Github/Github Enterprise and interacting with projects.
1010

11-
It supports authentication via simple access tokens, JWT endpoints and Github Apps (via DER private key).
11+
It supports authentication via simple access tokens, JWT endpoints and Github Apps (via private key).
1212

1313
It is also very light on GitHub, doing as few requests as necessary.
1414

@@ -51,7 +51,7 @@ To authenticate as a Github App, you must provide a private key and the App ID,
5151
final GitHubClient github =
5252
GitHubClient.create(
5353
URI.create("https://github.com/api/v3/"),
54-
new File("/path-to-the/private-key.der"),
54+
new File("/path-to-the/private-key.pem"),
5555
APP_ID);
5656
```
5757

@@ -69,14 +69,6 @@ It is also possible to provide the installation to the root client.
6969

7070
Refer to [Github App Authentication Guide](https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/) for more information.
7171

72-
#### Making the private key readable for Java
73-
74-
Java does not natively support the PEM private keys that GitHub provides, without a third-party library. To convert it to the DER, java-friendly format, run:
75-
76-
```bash
77-
openssl pkcs8 -topk8 -inform PEM -outform DER -in <path-to-private-key.pem> -out <path-to-private_key.der> -nocrypt
78-
```
79-
8072
## Usage
8173

8274
This library attempts to mirror the structure of GitHub API endpoints. As an example, to get details of a Commit, there is

checkstyle.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,13 @@
178178
<module name="TodoComment"/>
179179
<module name="UpperEll"/>
180180

181+
<!-- Enable suppressing warnings using the @SuppressWarning annotation -->
182+
<module name="SuppressWarningsHolder" />
181183
</module>
182184

185+
<!-- Enable suppressing warnings using the @SuppressWarning annotation -->
186+
<module name="SuppressWarningsFilter" />
187+
183188
<!-- Allow turning checks off in the code -->
184189
<!-- See http://checkstyle.sourceforge.net/config.html#SuppressionCommentFilter -->
185190
<!-- <module name="SuppressionCommentFilter"/>-->

src/main/java/com/spotify/github/v3/clients/GitHubClient.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ private String getAuthorizationHeader(final String path) {
525525
} else if (getPrivateKey().isPresent()) {
526526
final String jwtToken;
527527
try {
528-
jwtToken = new JwtTokenIssuer(privateKey).getToken(appId);
528+
jwtToken = JwtTokenIssuer.fromFile(privateKey).getToken(appId);
529529
} catch (Exception e) {
530530
throw new RuntimeException("There was an error generating JWT token", e);
531531
}

src/main/java/com/spotify/github/v3/clients/JwtTokenIssuer.java

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.security.NoSuchAlgorithmException;
2929
import java.security.PrivateKey;
3030
import java.security.spec.InvalidKeySpecException;
31+
import java.security.spec.KeySpec;
3132
import java.security.spec.PKCS8EncodedKeySpec;
3233
import java.util.Date;
3334
import org.apache.commons.io.FileUtils;
@@ -36,9 +37,13 @@
3637
public class JwtTokenIssuer {
3738

3839
private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.RS256;
40+
private static final long TOKEN_TTL = 600000;
3941

4042
private final PrivateKey signingKey;
41-
private static final long TOKEN_TTL = 600000;
43+
44+
private JwtTokenIssuer(final PrivateKey signingKey) {
45+
this.signingKey = signingKey;
46+
}
4247

4348
/**
4449
* Instantiates a new Jwt token issuer.
@@ -48,12 +53,28 @@ public class JwtTokenIssuer {
4853
* @throws InvalidKeySpecException the invalid key spec exception
4954
* @throws IOException the io exception
5055
*/
51-
public JwtTokenIssuer(final File privateKeyFile)
56+
public static JwtTokenIssuer fromFile(final File privateKeyFile)
5257
throws NoSuchAlgorithmException, InvalidKeySpecException, IOException {
5358
byte[] apiKeySecretBytes = FileUtils.readFileToByteArray(privateKeyFile);
54-
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(apiKeySecretBytes);
59+
return fromPrivateKey(apiKeySecretBytes);
60+
}
61+
62+
/**
63+
* Instantiates a new Jwt token issuer.
64+
*
65+
* @param privateKey the private key to use
66+
* @throws NoSuchAlgorithmException the no such algorithm exception
67+
* @throws InvalidKeySpecException the invalid key spec exception
68+
*/
69+
public static JwtTokenIssuer fromPrivateKey(final byte[] privateKey)
70+
throws NoSuchAlgorithmException, InvalidKeySpecException {
71+
72+
KeySpec keySpec = PKCS1PEMKey.loadKeySpec(privateKey)
73+
.orElseGet(() -> new PKCS8EncodedKeySpec(privateKey));
74+
5575
KeyFactory kf = KeyFactory.getInstance("RSA");
56-
signingKey = kf.generatePrivate(spec);
76+
PrivateKey signingKey = kf.generatePrivate(keySpec);
77+
return new JwtTokenIssuer(signingKey);
5778
}
5879

5980
/**
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*-
2+
* -\-\-
3+
* github-api
4+
* --
5+
* Copyright (C) 2016 - 2020 Spotify AB
6+
* --
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* -/-/-
19+
*/
20+
21+
package com.spotify.github.v3.clients;
22+
23+
import java.security.spec.KeySpec;
24+
import java.security.spec.PKCS8EncodedKeySpec;
25+
import java.util.Base64;
26+
import java.util.Optional;
27+
import java.util.regex.Matcher;
28+
import java.util.regex.Pattern;
29+
30+
/**
31+
* Loads PEM key files as issued by the Github apps page.
32+
*/
33+
final class PKCS1PEMKey {
34+
35+
private static final Pattern PKCS1_PEM_KEY_PATTERN =
36+
Pattern.compile("(?m)(?s)^---*BEGIN RSA PRIVATE KEY.*---*$(.*)^---*END.*---*$.*");
37+
38+
private PKCS1PEMKey() {}
39+
40+
/**
41+
* Try to interpret the supplied key as a PKCS#1 PEM file.
42+
*
43+
* @param privateKey the private key to use
44+
*/
45+
public static Optional<KeySpec> loadKeySpec(final byte[] privateKey) {
46+
final Matcher isPEM = PKCS1_PEM_KEY_PATTERN.matcher(new String(privateKey));
47+
if (!isPEM.matches()) {
48+
return Optional.empty();
49+
}
50+
51+
byte[] pkcs1Key = Base64.getMimeDecoder().decode(isPEM.group(1));
52+
byte[] pkcs8Key = toPkcs8(pkcs1Key);
53+
final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8Key);
54+
return Optional.of(keySpec);
55+
}
56+
57+
/**
58+
* Convert a PKCS#1 key to a PKCS#8 key.
59+
*
60+
* <p>The Github app key comes in PKCS#1 format, while the Java security utilities only natively
61+
* understand PKCS#8. Fortunately, we can convert between the two by adding the PKCS#8 headers
62+
* manually.
63+
*
64+
* <p>Adapted from code in https://github.com/Mastercard/client-encryption-java
65+
*/
66+
@SuppressWarnings("checkstyle:magicnumber")
67+
private static byte[] toPkcs8(final byte[] pkcs1Bytes) {
68+
final int pkcs1Length = pkcs1Bytes.length;
69+
final int totalLength = pkcs1Length + 22;
70+
byte[] pkcs8Header = new byte[] {
71+
0x30, (byte) 0x82, (byte) ((totalLength >> 8) & 0xff), (byte) (totalLength & 0xff), // Sequence + total length
72+
0x2, 0x1, 0x0, // Integer (0)
73+
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
74+
0x4, (byte) 0x82, (byte) ((pkcs1Length >> 8) & 0xff), (byte) (pkcs1Length & 0xff) // Octet string + length
75+
};
76+
77+
byte[] pkcs8bytes = new byte[pkcs8Header.length + pkcs1Bytes.length];
78+
System.arraycopy(pkcs8Header, 0, pkcs8bytes, 0, pkcs8Header.length);
79+
System.arraycopy(pkcs1Bytes, 0, pkcs8bytes, pkcs8Header.length, pkcs1Bytes.length);
80+
return pkcs8bytes;
81+
}
82+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*-
2+
* -\-\-
3+
* github-api
4+
* --
5+
* Copyright (C) 2020 Spotify AB
6+
* --
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* -/-/-
19+
*/
20+
21+
package com.spotify.github.v3.clients;
22+
23+
import static org.hamcrest.CoreMatchers.not;
24+
import static org.hamcrest.CoreMatchers.nullValue;
25+
import static org.hamcrest.MatcherAssert.assertThat;
26+
27+
import com.google.common.io.Resources;
28+
import java.net.URL;
29+
import org.junit.Test;
30+
31+
public class JwtTokenIssuerTest {
32+
33+
private static final URL DER_KEY_RESOURCE =
34+
Resources.getResource("com/spotify/github/v3/github-private-key");
35+
36+
// generated using this command: "openssl genrsa -out fake-github-app-key.pem 2048"
37+
private static final URL PEM_KEY_RESOURCE =
38+
Resources.getResource("com/spotify/github/v3/fake-github-app-key.pem");
39+
40+
@Test
41+
public void loadsDERFileWithPKCS8Key() throws Exception {
42+
final byte[] key = Resources.toByteArray(DER_KEY_RESOURCE);
43+
final JwtTokenIssuer tokenIssuer = JwtTokenIssuer.fromPrivateKey(key);
44+
45+
final String token = tokenIssuer.getToken(42);
46+
assertThat(token, not(nullValue()));
47+
}
48+
49+
@Test
50+
public void loadsPEMFile() throws Exception {
51+
final byte[] key = Resources.toByteArray(PEM_KEY_RESOURCE);
52+
final JwtTokenIssuer tokenIssuer = JwtTokenIssuer.fromPrivateKey(key);
53+
54+
final String token = tokenIssuer.getToken(42);
55+
assertThat(token, not(nullValue()));
56+
}
57+
58+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-----BEGIN RSA PRIVATE KEY-----
2+
MIIEowIBAAKCAQEAoNgYPu88oscyUIZbyRNOxsYBH0qtLshUnUScoOYWVkvt/6Gn
3+
8Eh6orxZs+G3LhMSd4l108X2UyQGe7DAQk2qr7+wxY/ZrWI3M4AYt154qagj2Z68
4+
OVQohZIqPUxMPx2R2AsL3L2WDoeILniNqkntQFn8RZ6LH+34PvtQQ055vgA66uWL
5+
bWBgR+sg7RPbhbYLf5n5UluApd6uXqRPU10NCzWww7U71ubjahmJVT7vcyEFmOmF
6+
u1Bf2K6f+raXIVkOFDPzxTq07vGmnkJHvc1BGFAwBnpLdqq34ZdFfv9F+kJ0tCyH
7+
eKuAx6gctPkDbHxxTp/0DG8NnOo+coBz2m09FQIDAQABAoIBAFIQr548tjVfaR6I
8+
zv/y5/inQh9THLWH5RQw07GMc80oBJCvTF5evKOXcjVDbxEFDiELc6DPmnSlJuGp
9+
Nw8dTX9KUMkcMjYyrHOMYg/9FZeKgHAie2rMs7gi8YZBDY4OakFOsYi4+n0DTcpY
10+
G//MpE53Gy3yTI3H/yczVqpgueDksgMNlOJhl+AvXN005vC+/uL9mNoOaAwTjYC8
11+
DrzKixQ5/uj9Eu+P8ctMzDAETzIasiQkPo9XP27CAADkqVbJFmg0INP4LAa1+xWs
12+
MbqyydCi3SD/L2PaztrDQQa4Nd6W1N0NEenqKuImYIYNSXagNcTZCzpendnMr5f1
13+
3I262SkCgYEA0Org/OrrysauyEzBl8svfLfM3ax3/ZXOCnYdyWS8gWCnychrP8tu
14+
4TzpUdqffIV9bafPB8fkwvD6fHd0EuJNAb6ZPwWcpz2tJyhCab1h9iZOMOD5G2Yi
15+
ZqTBNbYWgmGbDZLBa56fEciVF9UtS3k+1KPnHc61piHJ4txNFulu/b8CgYEAxRe1
16+
XSjYrRJSbuqCZ3Q6pOAskE/q/6rfH+7Cw6nOONaRXo9mvayqe0TbJcmZEeS6c8yc
17+
utWQR28UqMP6bSEnqWjU/ZoUiYNgsE8BaxQuzoZrydyxrXn/3gyiJ+a6Nhe8zDxt
18+
24i93KxZgo0Y+I1ECjsbce/s8UUOT0VfiLS64isCgYEAlZ2AHuDGmGONTFjb069p
19+
hLHEf4RCMlMUSZ2pW09PSIBF6VYkqH0yHRAYL8yXpv+agetJctMO2yTk3jpV4Cg8
20+
6eDrspx8QbEDziUg2sUL4NIx8QNMoviT7lpTG/oZSKpJ9oCBEGd6l6vESlsaoxBj
21+
lLkEjO46XI2aHWOTubLXD9UCgYAJkog1eRlk9oHYbz1cJvH+NgEUFT2Vo0fo9iCx
22+
fhrM+ebfj9luludEy2hVYoAztUc0/pgSHvM99PAs7i/Igxa5DKVjl8stjprwlTW9
23+
bKKFV1P+3uAmS8mYkEaD55ndrLN3u+ueAPsvr5M9Wvr+f2XxlUNU+lEourDiOr1U
24+
F2sINwKBgEM+72eQ82UOGCz/5UGcVR9vKLn6y0jbEZ8f04O/54uSZSxJ+ddPkGj7
25+
cWAQItbRV+Cjo+LzFy+sUPk0XJQny5aC5Iy//Qn1chYpek4IUELtz7UOYitL/xXU
26+
+ERPV2A7uNg9rKhmWKqz76wgp8SyucUdQERL2CeQEtoTT3gKXmWF
27+
-----END RSA PRIVATE KEY-----

0 commit comments

Comments
 (0)