Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JENKINS-68662] Rewrite PEMHelper to use BouncyCastle APIs #23

Merged
merged 2 commits into from
Jun 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,200 +1,79 @@
package org.jenkinsci.main.modules.instance_identity.pem;

import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.UnrecoverableKeyException;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.logging.Level;
import java.util.logging.Logger;

import edu.umd.cs.findbugs.annotations.NonNull;

import org.apache.commons.lang.StringUtils;
import jenkins.bouncycastle.api.PEMEncodable;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

// TODO consider switching to BouncyCastle APIs

/**
* Helper class to decode an encode PEM formated strings without any external dependencies
* The supported formats are:
* <ul>
* <li> PCKS8 encode and decode
* <li> PCKS1 only decode
* </ul>
* Helper class to decode and encode PEM formatted strings using {@link PEMEncodable}
*
* @see PEMEncodable
* @see PEMEncodable#encode()
* @see PEMEncodable#decode(String, char[])
*/
@Restricted(NoExternalUse.class)
public class PEMHelper {

private static final String BEGIN_RSA_PK = "-----BEGIN RSA PRIVATE KEY-----";
private static final String END_RSA_PK = "-----END RSA PRIVATE KEY-----";
private static final String BEGIN_PK = "-----BEGIN PRIVATE KEY-----";
private static final String END_PK = "-----END PRIVATE KEY-----";
private static final String PEM_LINE_SEP = "\n";
private static final int PEM_LINE_LENGTH = 64;

/**
* Decodes a PEM formated string to {@link KeyPair}. Only PCKS1 and PCKS8 formats are supported
* Decodes a PEM formatted string to {@link KeyPair}. Wrapper for {@link PEMEncodable#decode(String)}.
*
* @param pem {@link String} with the PEM format
* @return decoded PEM as {@link KeyPair}
* @throws IOException if a problem exists decoding the PEM
* @throws IOException if a problem exists decoding the PEM
* @see PEMEncodable#decode(String, char[])
*/
@NonNull
public static KeyPair decodePEM(@NonNull String pem) throws IOException {
KeySpec privKeySpec;

// obtain KeySpec according to the detected format
if (pem.startsWith(BEGIN_RSA_PK)) { // PCKS1
byte[] binaryPem = extractBinaryPEM(pem, BEGIN_RSA_PK, END_RSA_PK);
privKeySpec = newRSAPrivateCrtKeySpec(binaryPem);
} else if (pem.startsWith(BEGIN_PK)) { // PCKS8
byte[] binaryPem = extractBinaryPEM(pem, BEGIN_PK, END_PK);
privKeySpec = new PKCS8EncodedKeySpec(binaryPem);
} else {
throw new IOException("Could not read PEM file incorrect header.");
}

try {
//obtain the private key from the spec
KeyFactory kf = KeyFactory.getInstance("RSA");
PrivateKey privKey = kf.generatePrivate(privKeySpec);

if (privKey instanceof RSAPrivateCrtKey) {
//obtain public key spec from the private key
RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privKey;
RSAPublicKeySpec pubKeySpec = new RSAPublicKeySpec(rsaPrivateKey.getModulus(),
rsaPrivateKey.getPublicExponent());
return new KeyPair(kf.generatePublic(pubKeySpec), privKey);
final PEMEncodable decode = PEMEncodable.decode(pem);
KeyPair keyPair = decode.toKeyPair();
if (keyPair != null) {
return keyPair;
} else {
final Object rawObject = decode.getRawObject();
String received;
if (rawObject != null) {
received = rawObject.getClass().getName();
} else {
received = "null";
}
LOGGER.log(Level.SEVERE,
"Error reading private key, obtained unexpected result. Received {0} when expecting {1}",
new Object[] { privKey.getClass().getName(), RSAPrivateCrtKey.class.getName() });
new Object[]{received, RSAPrivateCrtKey.class.getName()});
jmdesprez marked this conversation as resolved.
Show resolved Hide resolved
throw new IOException("Error reading private key, obtained unexpected result.");
}

} catch (NoSuchAlgorithmException e) {
throw new AssertionError(
"RSA algorithm support is mandated by Java Language Specification. See https://docs.oracle.com/javase/7/docs/api/java/security/KeyFactory.html");
} catch (InvalidKeySpecException e) {
throw new IOException("Invalid key specification: " + e.getMessage());
} catch (UnrecoverableKeyException e) {
LOGGER.log(Level.SEVERE, "Error reading private key, obtained unexpected result.", e);
throw new IOException("Error reading private key, obtained unexpected result.");
}
}

/**
* Encodes a {@link KeyPair} in a PCKS8 PEM formated string.
*
* Encodes a {@link KeyPair} in a PCKS1 PEM formatted string. Wrapper for {@link PEMEncodable#encode()}.
*
* @param keys {@link KeyPair} to encode
* @return {@link KeyPair} as an encoded PEM String
* @throws IOException if a problem exists decoding the PEM
* @return {@link KeyPair} as an encoded PEM String
* @throws IOException if a problem exists decoding the PEM
* @see PEMEncodable#encode()
*/
@NonNull
public static String encodePEM(@NonNull KeyPair keys) throws IOException {

try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
BufferedWriter bw = new BufferedWriter(new PrintWriter(new OutputStreamWriter(baos, "UTF-8")));

bw.write(BEGIN_PK);
bw.write(PEM_LINE_SEP);

writeEncoded(keys.getPrivate().getEncoded(), bw);

bw.write(END_PK);
bw.write(PEM_LINE_SEP);
bw.close();

return baos.toString(StandardCharsets.UTF_8.name());
} catch (AssertionError e) {
throw new AssertionError(
"UTF-8 character set support is mandated by Java Language Specification. See https://docs.oracle.com/javase/7/docs/api/java/nio/charset/StandardCharsets.html");
}
}

private static byte[] extractBinaryPEM(String pem, String header, String footer) {
String stripedPEM = StringUtils.stripEnd(StringUtils.strip(pem.trim(), header), footer);
return Base64.getMimeDecoder().decode(stripedPEM);
}

/**
* Convert PKCS#1 encoded private key into RSAPrivateCrtKeySpec.
*
* <p/>
* The ASN.1 syntax for the private key with CRT is
*
* <pre>
* --
* -- Representation of RSA private key with information for the CRT algorithm.
* --
* RSAPrivateKey ::= SEQUENCE {
* version Version,
* modulus INTEGER, -- n
* publicExponent INTEGER, -- e
* privateExponent INTEGER, -- d
* prime1 INTEGER, -- p
* prime2 INTEGER, -- q
* exponent1 INTEGER, -- d mod (p-1)
* exponent2 INTEGER, -- d mod (q-1)
* coefficient INTEGER, -- (inverse of q) mod p
* otherPrimeInfos OtherPrimeInfos OPTIONAL
* }
* </pre>
* See p.41 of http://www.emc.com/emc-plus/rsa-labs/pkcs/files/h11300-wp-pkcs-1v2-2-rsa-cryptography-standard.pdf
* @param keyInPkcs1 PKCS#1 encoded key
* @throws IOException
*/
private static RSAPrivateCrtKeySpec newRSAPrivateCrtKeySpec(byte[] keyInPkcs1) throws IOException {

DerParser parser = new DerParser(keyInPkcs1);
Asn1Object sequence = parser.read();
if (sequence.getType() != DerParser.SEQUENCE)
throw new IllegalArgumentException("Invalid DER: not a sequence");

// Parse inside the sequence
DerParser seqParser = sequence.getParser();

seqParser.read(); // Skip version
BigInteger modulus = seqParser.read().getInteger();
BigInteger publicExp = seqParser.read().getInteger();
BigInteger privateExp = seqParser.read().getInteger();
BigInteger prime1 = seqParser.read().getInteger();
BigInteger prime2 = seqParser.read().getInteger();
BigInteger exp1 = seqParser.read().getInteger();
BigInteger exp2 = seqParser.read().getInteger();
BigInteger crtCoef = seqParser.read().getInteger();

RSAPrivateCrtKeySpec keySpec = new RSAPrivateCrtKeySpec(modulus, publicExp, privateExp, prime1, prime2, exp1,
exp2, crtCoef);

return keySpec;
}

private static void writeEncoded(byte[] bytes, BufferedWriter wr) throws IOException {
char[] buf = new char[PEM_LINE_LENGTH];
bytes = Base64.getEncoder().encode(bytes);
// getMimeEncoder() may or may not put the NL in the end, which is inconvenient because
// we want to always print NL at the end
for (int i = 0; i < bytes.length; i += buf.length) {
int index;
for (index = 0; index < buf.length && (i + index) < bytes.length; index++) {
buf[index] = (char) bytes[i + index];
}
wr.write(buf, 0, index);
wr.write(PEM_LINE_SEP);
final PEMEncodable pemEncodable = PEMEncodable.create(keys.getPrivate());
return pemEncodable.encode();
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Error writing private key, obtained unexpected result.", e);
throw new IOException("Error writing private key, obtained unexpected result.");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@

import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;

import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.Security;

import org.apache.commons.io.FileUtils;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.jenkinsci.main.modules.instance_identity.pem.PEMHelper;
import org.junit.BeforeClass;
import org.junit.Rule;
Expand All @@ -58,12 +62,13 @@ public static void setUpBC() throws URISyntaxException, IOException {
ReadWriteKeyTest.class.getClassLoader().getResource("private-key-private-encoded.bin").toURI()));
KEY_PUBLIC_ENCODED = FileUtils.readFileToByteArray(new File(
ReadWriteKeyTest.class.getClassLoader().getResource("private-key-public-encoded.bin").toURI()));
Security.addProvider(new BouncyCastleProvider());
}

@Test
public void testReadIdentityPKCS1vsPKCS8() throws Exception {
String pcks1PEM = FileUtils.readFileToString(PEM_PCKS1_FILE);
String pcks8PEM = FileUtils.readFileToString(PEM_PCKS8_FILE);
String pcks1PEM = FileUtils.readFileToString(PEM_PCKS1_FILE, StandardCharsets.UTF_8);
String pcks8PEM = FileUtils.readFileToString(PEM_PCKS8_FILE, StandardCharsets.UTF_8);

KeyPair keyPair1 = PEMHelper.decodePEM(pcks1PEM);
KeyPair keyPair8 = PEMHelper.decodePEM(pcks8PEM);
Expand All @@ -72,26 +77,43 @@ public void testReadIdentityPKCS1vsPKCS8() throws Exception {
assertArrayEquals(keyPair1.getPublic().getEncoded(), keyPair8.getPublic().getEncoded());
}

/**
* Invalid PEM should throw an IOException
*/
@Test
public void testDecodeInvalidIdentity() {
assertThrows(IOException.class, () -> PEMHelper.decodePEM("not valid"));
}

/**
* Invalid PEM should throw an IOException
*/
@Test
public void testEncodeInvalidIdentity() {
assertThrows(IOException.class, () -> PEMHelper.encodePEM(new KeyPair(null, null)));
}

@Test
public void testWriteIdentityPKCS1vsPKCS8() throws Exception {
String pcksPEM = FileUtils.readFileToString(PEM_PCKS8_FILE);
String pcks1PEM = FileUtils.readFileToString(PEM_PCKS1_FILE, StandardCharsets.UTF_8);
String pcks8PEM = FileUtils.readFileToString(PEM_PCKS8_FILE, StandardCharsets.UTF_8);

KeyPair keyPair = PEMHelper.decodePEM(pcksPEM);
KeyPair keyPair = PEMHelper.decodePEM(pcks8PEM);
String encodedPEM = PEMHelper.encodePEM(keyPair);

assertEquals(unifyEOL(pcksPEM), unifyEOL(encodedPEM));
assertEquals(unifyEOL(pcks1PEM), unifyEOL(encodedPEM));
}

@Test
public void testCompareReadPKCS1AndPCKS8() throws Exception {
String pcksPEM = FileUtils.readFileToString(PEM_PCKS1_FILE);
String pcks1PEM = FileUtils.readFileToString(PEM_PCKS1_FILE, StandardCharsets.UTF_8);

KeyPair keyPair = PEMHelper.decodePEM(pcksPEM);
KeyPair keyPair = PEMHelper.decodePEM(pcks1PEM);
String reEncodedPEM = PEMHelper.encodePEM(keyPair);

assertArrayEquals(keyPair.getPrivate().getEncoded(), KEY_PRIVATE_ENCODED);
assertArrayEquals(keyPair.getPublic().getEncoded(), KEY_PUBLIC_ENCODED);
assertEquals(unifyEOL(reEncodedPEM), unifyEOL(FileUtils.readFileToString(PEM_PCKS8_FILE)));
assertEquals(unifyEOL(reEncodedPEM), unifyEOL(FileUtils.readFileToString(PEM_PCKS1_FILE, StandardCharsets.UTF_8)));

// reread the newly encoded keyPair and retest
KeyPair keyPair2 = PEMHelper.decodePEM(reEncodedPEM);
Expand Down