From c62a0188304dde3eb9b69674d88bb7a30ef5b328 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 4 Nov 2024 15:49:37 -0800 Subject: [PATCH] Align buildpack certificate and key parsers with SSL versions Closes gh-37969 --- .../platform/docker/ssl/KeyStoreFactory.java | 13 +- ...eParser.java => PemCertificateParser.java} | 39 +-- ...eyParser.java => PemPrivateKeyParser.java} | 286 ++++++++++++++---- .../docker/ssl/CertificateParserTests.java | 80 ----- .../docker/ssl/PemCertificateParserTests.java | 41 +++ .../docker/ssl/PemPrivateKeyParserTests.java | 41 +++ .../docker/ssl/PrivateKeyParserTests.java | 165 ---------- .../platform/docker/ssl/SslSource.java | 54 ++++ 8 files changed, 383 insertions(+), 336 deletions(-) rename spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/{CertificateParser.java => PemCertificateParser.java} (71%) rename spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/{PrivateKeyParser.java => PemPrivateKeyParser.java} (50%) delete mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParserTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemCertificateParserTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemPrivateKeyParserTests.java delete mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/SslSource.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactory.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactory.java index f54ac5cbfe..f84fa58c96 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactory.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.List; /** * Utility methods for creating Java trust material from key and certificate files. @@ -50,10 +51,11 @@ final class KeyStoreFactory { static KeyStore create(Path certPath, Path keyPath, String alias) { try { KeyStore keyStore = getKeyStore(); - X509Certificate[] certificates = CertificateParser.parse(certPath); + String certificateText = Files.readString(certPath); + List certificates = PemCertificateParser.parse(certificateText); PrivateKey privateKey = getPrivateKey(keyPath); try { - addCertificates(keyStore, certificates, privateKey, alias); + addCertificates(keyStore, certificates.toArray(X509Certificate[]::new), privateKey, alias); } catch (KeyStoreException ex) { throw new IllegalStateException("Error adding certificates to KeyStore: " + ex.getMessage(), ex); @@ -72,9 +74,10 @@ final class KeyStoreFactory { return keyStore; } - private static PrivateKey getPrivateKey(Path path) { + private static PrivateKey getPrivateKey(Path path) throws IOException { if (path != null && Files.exists(path)) { - return PrivateKeyParser.parse(path); + String text = Files.readString(path); + return PemPrivateKeyParser.parse(text); } return null; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParser.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PemCertificateParser.java similarity index 71% rename from spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParser.java rename to spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PemCertificateParser.java index 1e25c282ba..cb541db60a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParser.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PemCertificateParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,9 +17,6 @@ package org.springframework.boot.buildpack.platform.docker.ssl; import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; @@ -30,13 +27,16 @@ import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + /** * Parser for X.509 certificates in PEM format. * * @author Scott Frederick * @author Phillip Webb */ -final class CertificateParser { +final class PemCertificateParser { private static final String HEADER = "-+BEGIN\\s+.*CERTIFICATE[^-]*-+(?:\\s|\\r|\\n)+"; @@ -46,21 +46,23 @@ final class CertificateParser { private static final Pattern PATTERN = Pattern.compile(HEADER + BASE64_TEXT + FOOTER, Pattern.CASE_INSENSITIVE); - private CertificateParser() { + private PemCertificateParser() { } /** - * Load certificates from the specified file paths. - * @param paths one or more paths to certificate files - * @return certificates parsed from specified file paths + * Parse certificates from the specified string. + * @param text the text to parse + * @return the parsed certificates */ - static X509Certificate[] parse(Path... paths) { - CertificateFactory factory = getCertificateFactory(); - List certificates = new ArrayList<>(); - for (Path path : paths) { - readCertificates(path, factory, certificates::add); + static List parse(String text) { + if (text == null) { + return null; } - return certificates.toArray(new X509Certificate[0]); + CertificateFactory factory = getCertificateFactory(); + List certs = new ArrayList<>(); + readCertificates(text, factory, certs::add); + Assert.state(!CollectionUtils.isEmpty(certs), "Missing certificates or unrecognized format"); + return List.copyOf(certs); } private static CertificateFactory getCertificateFactory() { @@ -72,9 +74,8 @@ final class CertificateParser { } } - private static void readCertificates(Path path, CertificateFactory factory, Consumer consumer) { + private static void readCertificates(String text, CertificateFactory factory, Consumer consumer) { try { - String text = Files.readString(path); Matcher matcher = PATTERN.matcher(text); while (matcher.find()) { String encodedText = matcher.group(1); @@ -85,8 +86,8 @@ final class CertificateParser { } } } - catch (CertificateException | IOException ex) { - throw new IllegalStateException("Error reading certificate from '" + path + "' : " + ex.getMessage(), ex); + catch (CertificateException ex) { + throw new IllegalStateException("Error reading certificate: " + ex.getMessage(), ex); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PemPrivateKeyParser.java similarity index 50% rename from spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java rename to spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PemPrivateKeyParser.java index 480392a032..45e4e5c9b4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PemPrivateKeyParser.java @@ -19,23 +19,33 @@ package org.springframework.boot.buildpack.platform.docker.ssl; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; -import java.nio.file.Files; -import java.nio.file.Path; +import java.security.AlgorithmParameters; +import java.security.GeneralSecurityException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.HashMap; +import java.util.HexFormat; import java.util.List; -import java.util.function.Function; +import java.util.Map; +import java.util.function.BiFunction; import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.springframework.boot.buildpack.platform.docker.ssl.PrivateKeyParser.DerElement.TagType; -import org.springframework.boot.buildpack.platform.docker.ssl.PrivateKeyParser.DerElement.ValueType; +import javax.crypto.Cipher; +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +import org.springframework.boot.buildpack.platform.docker.ssl.PemPrivateKeyParser.DerElement.TagType; +import org.springframework.boot.buildpack.platform.docker.ssl.PemPrivateKeyParser.DerElement.ValueType; import org.springframework.util.Assert; /** @@ -45,7 +55,7 @@ import org.springframework.util.Assert; * @author Phillip Webb * @author Moritz Halbritter */ -final class PrivateKeyParser { +final class PemPrivateKeyParser { private static final String PKCS1_RSA_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; @@ -55,46 +65,59 @@ final class PrivateKeyParser { private static final String PKCS8_FOOTER = "-+END\\s+PRIVATE\\s+KEY[^-]*-+"; + private static final String PKCS8_ENCRYPTED_HEADER = "-+BEGIN\\s+ENCRYPTED\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String PKCS8_ENCRYPTED_FOOTER = "-+END\\s+ENCRYPTED\\s+PRIVATE\\s+KEY[^-]*-+"; + private static final String SEC1_EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; private static final String SEC1_EC_FOOTER = "-+END\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+"; private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)"; + public static final int BASE64_TEXT_GROUP = 1; + + private static final EncodedOid RSA_ALGORITHM = EncodedOid.OID_1_2_840_113549_1_1_1; + + private static final EncodedOid ELLIPTIC_CURVE_ALGORITHM = EncodedOid.OID_1_2_840_10045_2_1; + + private static final EncodedOid ELLIPTIC_CURVE_384_BIT = EncodedOid.OID_1_3_132_0_34; + + private static final Map ALGORITHMS; + static { + Map algorithms = new HashMap<>(); + algorithms.put(EncodedOid.OID_1_2_840_113549_1_1_1, "RSA"); + algorithms.put(EncodedOid.OID_1_2_840_113549_1_1_10, "RSA"); + algorithms.put(EncodedOid.OID_1_2_840_10040_4_1, "DSA"); + algorithms.put(EncodedOid.OID_1_3_101_110, "XDH"); + algorithms.put(EncodedOid.OID_1_3_101_111, "XDH"); + algorithms.put(EncodedOid.OID_1_3_101_112, "EdDSA"); + algorithms.put(EncodedOid.OID_1_3_101_113, "EdDSA"); + algorithms.put(EncodedOid.OID_1_2_840_10045_2_1, "EC"); + ALGORITHMS = Collections.unmodifiableMap(algorithms); + } + private static final List PEM_PARSERS; static { List parsers = new ArrayList<>(); - parsers - .add(new PemParser(PKCS1_RSA_HEADER, PKCS1_RSA_FOOTER, PrivateKeyParser::createKeySpecForPkcs1Rsa, "RSA")); - parsers.add(new PemParser(SEC1_EC_HEADER, SEC1_EC_FOOTER, PrivateKeyParser::createKeySpecForSec1Ec, "EC")); - parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PKCS8EncodedKeySpec::new, "RSA", "RSASSA-PSS", "EC", - "DSA", "EdDSA", "XDH")); + parsers.add(new PemParser(PKCS1_RSA_HEADER, PKCS1_RSA_FOOTER, PemPrivateKeyParser::createKeySpecForPkcs1Rsa, + "RSA")); + parsers.add(new PemParser(SEC1_EC_HEADER, SEC1_EC_FOOTER, PemPrivateKeyParser::createKeySpecForSec1Ec, "EC")); + parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PemPrivateKeyParser::createKeySpecForPkcs8, "RSA", + "RSASSA-PSS", "EC", "DSA", "EdDSA", "XDH")); + parsers.add(new PemParser(PKCS8_ENCRYPTED_HEADER, PKCS8_ENCRYPTED_FOOTER, + PemPrivateKeyParser::createKeySpecForPkcs8Encrypted, "RSA", "RSASSA-PSS", "EC", "DSA", "EdDSA", "XDH")); PEM_PARSERS = Collections.unmodifiableList(parsers); } - /** - * ASN.1 encoded object identifier {@literal 1.2.840.113549.1.1.1}. - */ - private static final int[] RSA_ALGORITHM = { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01 }; - - /** - * ASN.1 encoded object identifier {@literal 1.2.840.10045.2.1}. - */ - private static final int[] EC_ALGORITHM = { 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01 }; - - /** - * ASN.1 encoded object identifier {@literal 1.3.132.0.34}. - */ - private static final int[] EC_PARAMETERS = { 0x2b, 0x81, 0x04, 0x00, 0x22 }; - - private PrivateKeyParser() { + private PemPrivateKeyParser() { } - private static PKCS8EncodedKeySpec createKeySpecForPkcs1Rsa(byte[] bytes) { + private static PKCS8EncodedKeySpec createKeySpecForPkcs1Rsa(byte[] bytes, String password) { return createKeySpecForAlgorithm(bytes, RSA_ALGORITHM, null); } - private static PKCS8EncodedKeySpec createKeySpecForSec1Ec(byte[] bytes) { + private static PKCS8EncodedKeySpec createKeySpecForSec1Ec(byte[] bytes, String password) { DerElement ecPrivateKey = DerElement.of(bytes); Assert.state(ecPrivateKey.isType(ValueType.ENCODED, TagType.SEQUENCE), "Key spec should be an ASN.1 encoded sequence"); @@ -107,37 +130,29 @@ final class PrivateKeyParser { Assert.state(privateKey != null && privateKey.isType(ValueType.PRIMITIVE, TagType.OCTET_STRING), "Key spec should contain private key"); DerElement parameters = DerElement.of(ecPrivateKey.getContents()); - return createKeySpecForAlgorithm(bytes, EC_ALGORITHM, getEcParameters(parameters)); + return createKeySpecForAlgorithm(bytes, ELLIPTIC_CURVE_ALGORITHM, getEcParameters(parameters)); } - private static int[] getEcParameters(DerElement parameters) { + private static EncodedOid getEcParameters(DerElement parameters) { if (parameters == null) { - return EC_PARAMETERS; + return ELLIPTIC_CURVE_384_BIT; } Assert.state(parameters.isType(ValueType.ENCODED), "Key spec should contain encoded parameters"); DerElement contents = DerElement.of(parameters.getContents()); - Assert.state(contents.isType(ValueType.PRIMITIVE, TagType.OBJECT_IDENTIFIER), + Assert.state(contents != null && contents.isType(ValueType.PRIMITIVE, TagType.OBJECT_IDENTIFIER), "Key spec parameters should contain object identifier"); - return getEcParameters(contents.getContents()); + return EncodedOid.of(contents); } - private static int[] getEcParameters(ByteBuffer bytes) { - int[] result = new int[bytes.remaining()]; - for (int i = 0; i < result.length; i++) { - result[i] = bytes.get() & 0xFF; - } - return result; - } - - private static PKCS8EncodedKeySpec createKeySpecForAlgorithm(byte[] bytes, int[] algorithm, int[] parameters) { + private static PKCS8EncodedKeySpec createKeySpecForAlgorithm(byte[] bytes, EncodedOid algorithm, + EncodedOid parameters) { try { DerEncoder encoder = new DerEncoder(); encoder.integer(0x00); // Version 0 DerEncoder algorithmIdentifier = new DerEncoder(); algorithmIdentifier.objectIdentifier(algorithm); algorithmIdentifier.objectIdentifier(parameters); - byte[] byteArray = algorithmIdentifier.toByteArray(); - encoder.sequence(byteArray); + encoder.sequence(algorithmIdentifier.toByteArray()); encoder.octetString(bytes); return new PKCS8EncodedKeySpec(encoder.toSequence()); } @@ -146,25 +161,59 @@ final class PrivateKeyParser { } } + private static PKCS8EncodedKeySpec createKeySpecForPkcs8(byte[] bytes, String password) { + DerElement ecPrivateKey = DerElement.of(bytes); + Assert.state(ecPrivateKey.isType(ValueType.ENCODED, TagType.SEQUENCE), + "Key spec should be an ASN.1 encoded sequence"); + DerElement version = DerElement.of(ecPrivateKey.getContents()); + Assert.state(version != null && version.isType(ValueType.PRIMITIVE, TagType.INTEGER), + "Key spec should start with version"); + DerElement sequence = DerElement.of(ecPrivateKey.getContents()); + Assert.state(sequence != null && sequence.isType(ValueType.ENCODED, TagType.SEQUENCE), + "Key spec should contain private key"); + DerElement algorithmId = DerElement.of(sequence.getContents()); + Assert.state(algorithmId != null && algorithmId.isType(ValueType.PRIMITIVE, TagType.OBJECT_IDENTIFIER), + "Key spec container object identifier"); + String algorithmName = ALGORITHMS.get(EncodedOid.of(algorithmId)); + return (algorithmName != null) ? new PKCS8EncodedKeySpec(bytes, algorithmName) : new PKCS8EncodedKeySpec(bytes); + } + + private static PKCS8EncodedKeySpec createKeySpecForPkcs8Encrypted(byte[] bytes, String password) { + return Pkcs8PrivateKeyDecryptor.decrypt(bytes, password); + } + /** - * Load a private key from the specified file paths. - * @param path the path to the private key file - * @return private key from specified file path + * Parse a private key from the specified string. + * @param text the text to parse + * @return the parsed private key */ - static PrivateKey parse(Path path) { + static PrivateKey parse(String text) { + return parse(text, null); + } + + /** + * Parse a private key from the specified string, using the provided password for + * decryption if necessary. + * @param text the text to parse + * @param password the password used to decrypt an encrypted private key + * @return the parsed private key + */ + static PrivateKey parse(String text, String password) { + if (text == null) { + return null; + } try { - String text = Files.readString(path); for (PemParser pemParser : PEM_PARSERS) { - PrivateKey privateKey = pemParser.parse(text); + PrivateKey privateKey = pemParser.parse(text, password); if (privateKey != null) { return privateKey; } } - throw new IllegalStateException("Unrecognized private key format"); } catch (Exception ex) { - throw new IllegalStateException("Error loading private key file " + path, ex); + throw new IllegalStateException("Error loading private key file: " + ex.getMessage(), ex); } + throw new IllegalStateException("Missing private key or unrecognized format"); } /** @@ -174,20 +223,20 @@ final class PrivateKeyParser { private final Pattern pattern; - private final Function keySpecFactory; + private final BiFunction keySpecFactory; private final String[] algorithms; - PemParser(String header, String footer, Function keySpecFactory, + PemParser(String header, String footer, BiFunction keySpecFactory, String... algorithms) { this.pattern = Pattern.compile(header + BASE64_TEXT + footer, Pattern.CASE_INSENSITIVE); - this.algorithms = algorithms; this.keySpecFactory = keySpecFactory; + this.algorithms = algorithms; } - PrivateKey parse(String text) { + PrivateKey parse(String text, String password) { Matcher matcher = this.pattern.matcher(text); - return (!matcher.find()) ? null : parse(decodeBase64(matcher.group(1))); + return (!matcher.find()) ? null : parse(decodeBase64(matcher.group(BASE64_TEXT_GROUP)), password); } private static byte[] decodeBase64(String content) { @@ -195,8 +244,17 @@ final class PrivateKeyParser { return Base64.getDecoder().decode(contentBytes); } - private PrivateKey parse(byte[] bytes) { - PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes); + private PrivateKey parse(byte[] bytes, String password) { + PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes, password); + if (keySpec.getAlgorithm() != null) { + try { + KeyFactory keyFactory = KeyFactory.getInstance(keySpec.getAlgorithm()); + return keyFactory.generatePrivate(keySpec); + } + catch (InvalidKeySpecException | NoSuchAlgorithmException ex) { + // Ignore + } + } for (String algorithm : this.algorithms) { try { KeyFactory keyFactory = KeyFactory.getInstance(algorithm); @@ -218,9 +276,9 @@ final class PrivateKeyParser { private final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - void objectIdentifier(int... encodedObjectIdentifier) throws IOException { - int code = (encodedObjectIdentifier != null) ? 0x06 : 0x05; - codeLengthBytes(code, bytes(encodedObjectIdentifier)); + void objectIdentifier(EncodedOid encodedOid) throws IOException { + int code = (encodedOid != null) ? 0x06 : 0x05; + codeLengthBytes(code, (encodedOid != null) ? encodedOid.toByteArray() : null); } void integer(int... encodedInteger) throws IOException { @@ -231,10 +289,6 @@ final class PrivateKeyParser { codeLengthBytes(0x04, bytes); } - void sequence(int... elements) throws IOException { - sequence(bytes(elements)); - } - void sequence(byte[] bytes) throws IOException { codeLengthBytes(0x30, bytes); } @@ -383,4 +437,102 @@ final class PrivateKeyParser { } + /** + * Decryptor for PKCS8 encoded private keys. + */ + static class Pkcs8PrivateKeyDecryptor { + + public static final String PBES2_ALGORITHM = "PBES2"; + + static PKCS8EncodedKeySpec decrypt(byte[] bytes, String password) { + Assert.notNull(password, "Password is required for an encrypted private key"); + try { + EncryptedPrivateKeyInfo keyInfo = new EncryptedPrivateKeyInfo(bytes); + AlgorithmParameters algorithmParameters = keyInfo.getAlgParameters(); + String encryptionAlgorithm = getEncryptionAlgorithm(algorithmParameters, keyInfo.getAlgName()); + SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(encryptionAlgorithm); + SecretKey key = keyFactory.generateSecret(new PBEKeySpec(password.toCharArray())); + Cipher cipher = Cipher.getInstance(encryptionAlgorithm); + cipher.init(Cipher.DECRYPT_MODE, key, algorithmParameters); + return keyInfo.getKeySpec(cipher); + } + catch (IOException | GeneralSecurityException ex) { + throw new IllegalArgumentException("Error decrypting private key", ex); + } + } + + private static String getEncryptionAlgorithm(AlgorithmParameters algParameters, String algName) { + if (algParameters != null && PBES2_ALGORITHM.equals(algName)) { + return algParameters.toString(); + } + return algName; + } + + } + + /** + * ANS.1 encoded object identifier. + */ + static final class EncodedOid { + + static final EncodedOid OID_1_2_840_10040_4_1 = EncodedOid.of("2a8648ce380401"); + static final EncodedOid OID_1_2_840_113549_1_1_1 = EncodedOid.of("2A864886F70D010101"); + static final EncodedOid OID_1_2_840_113549_1_1_10 = EncodedOid.of("2a864886f70d01010a"); + static final EncodedOid OID_1_3_101_110 = EncodedOid.of("2b656e"); + static final EncodedOid OID_1_3_101_111 = EncodedOid.of("2b656f"); + static final EncodedOid OID_1_3_101_112 = EncodedOid.of("2b6570"); + static final EncodedOid OID_1_3_101_113 = EncodedOid.of("2b6571"); + static final EncodedOid OID_1_2_840_10045_2_1 = EncodedOid.of("2a8648ce3d0201"); + static final EncodedOid OID_1_3_132_0_34 = EncodedOid.of("2b81040022"); + + private final byte[] value; + + private EncodedOid(byte[] value) { + this.value = value; + } + + byte[] toByteArray() { + return this.value.clone(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Arrays.equals(this.value, ((EncodedOid) obj).value); + } + + @Override + public int hashCode() { + return Arrays.hashCode(this.value); + } + + static EncodedOid of(String hexString) { + return of(HexFormat.of().parseHex(hexString)); + } + + static EncodedOid of(DerElement derElement) { + return of(derElement.getContents()); + } + + static EncodedOid of(ByteBuffer byteBuffer) { + return of(byteBuffer.array(), byteBuffer.arrayOffset() + byteBuffer.position(), byteBuffer.remaining()); + } + + static EncodedOid of(byte[] bytes) { + return of(bytes, 0, bytes.length); + } + + static EncodedOid of(byte[] bytes, int off, int len) { + byte[] value = new byte[len]; + System.arraycopy(bytes, off, value, 0, len); + return new EncodedOid(value); + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParserTests.java deleted file mode 100644 index dbb9deebe6..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParserTests.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * 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 - * - * https://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 org.springframework.boot.buildpack.platform.docker.ssl; - -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.cert.X509Certificate; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; - -/** - * Tests for {@link CertificateParser}. - * - * @author Scott Frederick - */ -class CertificateParserTests { - - private PemFileWriter fileWriter; - - @BeforeEach - void setUp() throws IOException { - this.fileWriter = new PemFileWriter(); - } - - @AfterEach - void tearDown() throws IOException { - this.fileWriter.cleanup(); - } - - @Test - void parseCertificates() throws IOException { - Path caPath = this.fileWriter.writeFile("ca.pem", PemFileWriter.CA_CERTIFICATE); - Path certPath = this.fileWriter.writeFile("cert.pem", PemFileWriter.CERTIFICATE); - X509Certificate[] certificates = CertificateParser.parse(caPath, certPath); - assertThat(certificates).isNotNull(); - assertThat(certificates).hasSize(2); - assertThat(certificates[0].getType()).isEqualTo("X.509"); - assertThat(certificates[1].getType()).isEqualTo("X.509"); - } - - @Test - void parseCertificateChain() throws IOException { - Path path = this.fileWriter.writeFile("ca.pem", PemFileWriter.CA_CERTIFICATE, PemFileWriter.CERTIFICATE); - X509Certificate[] certificates = CertificateParser.parse(path); - assertThat(certificates).isNotNull(); - assertThat(certificates).hasSize(2); - assertThat(certificates[0].getType()).isEqualTo("X.509"); - assertThat(certificates[1].getType()).isEqualTo("X.509"); - } - - @Test - void parseWithInvalidPathWillThrowException() throws URISyntaxException { - Path path = Paths.get(new URI("file:///bad/path/cert.pem")); - assertThatIllegalStateException().isThrownBy(() -> CertificateParser.parse(path)) - .withMessageContaining(path.toString()); - } - -} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemCertificateParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemCertificateParserTests.java new file mode 100644 index 0000000000..cac9f83122 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemCertificateParserTests.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PemCertificateParser}. + * + * @author Phillip Webb + */ +class PemCertificateParserTests { + + private static final String SOURCE = "PemCertificateParser.java"; + + @Test + void codeShouldMatchSpringBootSslPackage() throws IOException { + String buildpackVersion = SslSource.loadBuildpackVersion(SOURCE); + String springBootVersion = SslSource.loadSpringBootVersion(SOURCE); + assertThat(buildpackVersion).isEqualTo(springBootVersion); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemPrivateKeyParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemPrivateKeyParserTests.java new file mode 100644 index 0000000000..ac2a7ac031 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemPrivateKeyParserTests.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PemPrivateKeyParser}. + * + * @author Phillip Webb + */ +class PemPrivateKeyParserTests { + + private static final String SOURCE = "PemPrivateKeyParser.java"; + + @Test + void codeShouldMatchSpringBootSslPackage() throws IOException { + String buildpackVersion = SslSource.loadBuildpackVersion(SOURCE); + String springBootVersion = SslSource.loadSpringBootVersion(SOURCE); + assertThat(buildpackVersion).isEqualTo(springBootVersion); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java deleted file mode 100644 index f9a615fe0f..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * 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 - * - * https://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 org.springframework.boot.buildpack.platform.docker.ssl; - -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.PrivateKey; -import java.security.interfaces.ECPrivateKey; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import org.springframework.boot.buildpack.platform.docker.ssl.PrivateKeyParser.DerEncoder; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; - -/** - * Tests for {@link PrivateKeyParser}. - * - * @author Scott Frederick - * @author Phillip Webb - * @author Moritz Halbritter - */ -class PrivateKeyParserTests { - - private PemFileWriter fileWriter; - - @BeforeEach - void setUp() throws IOException { - this.fileWriter = new PemFileWriter(); - } - - @AfterEach - void tearDown() throws IOException { - this.fileWriter.cleanup(); - } - - @Test - void parsePkcs8RsaKeyFile() throws IOException { - Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PKCS8_PRIVATE_RSA_KEY); - PrivateKey privateKey = PrivateKeyParser.parse(path); - assertThat(privateKey).isNotNull(); - assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); - } - - @ParameterizedTest - @ValueSource(strings = { PemFileWriter.PKCS8_PRIVATE_EC_NIST_P256_KEY, PemFileWriter.PKCS8_PRIVATE_EC_NIST_P384_KEY, - PemFileWriter.PKCS8_PRIVATE_EC_PRIME256V1_KEY, PemFileWriter.PKCS8_PRIVATE_EC_SECP256R1_KEY }) - void parsePkcs8EcKeyFile(String contents) throws IOException { - Path path = this.fileWriter.writeFile("key.pem", contents); - PrivateKey privateKey = PrivateKeyParser.parse(path); - assertThat(privateKey).isNotNull(); - assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); - assertThat(privateKey.getAlgorithm()).isEqualTo("EC"); - } - - @Test - void parsePkcs8DsaKeyFile() throws IOException { - Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_DSA_KEY); - PrivateKey privateKey = PrivateKeyParser.parse(path); - assertThat(privateKey).isNotNull(); - assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); - assertThat(privateKey.getAlgorithm()).isEqualTo("DSA"); - } - - @Test - void parsePkcs1RsaKeyFile() throws IOException { - Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_RSA_KEY); - PrivateKey privateKey = PrivateKeyParser.parse(path); - assertThat(privateKey).isNotNull(); - assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); - } - - @Test - void parsePemEcKeyFile() throws IOException { - Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_EC_KEY); - ECPrivateKey privateKey = (ECPrivateKey) PrivateKeyParser.parse(path); - assertThat(privateKey).isNotNull(); - assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); - assertThat(privateKey.getAlgorithm()).isEqualTo("EC"); - assertThat(privateKey.getParams().toString()).contains("1.3.132.0.34").doesNotContain("prime256v1"); - } - - @Test - void parsePemEcKeyFilePrime256v1() throws IOException { - Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_EC_KEY_PRIME_256_V1); - ECPrivateKey privateKey = (ECPrivateKey) PrivateKeyParser.parse(path); - assertThat(privateKey).isNotNull(); - assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); - assertThat(privateKey.getAlgorithm()).isEqualTo("EC"); - assertThat(privateKey.getParams().toString()).contains("prime256v1").doesNotContain("1.3.132.0.34"); - } - - @Test - void parsePkcs8Ed25519KeyFile() throws IOException { - Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PKCS8_PRIVATE_EC_ED25519_KEY); - PrivateKey privateKey = PrivateKeyParser.parse(path); - assertThat(privateKey).isNotNull(); - assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); - assertThat(privateKey.getAlgorithm()).isEqualTo("EdDSA"); - } - - @Test - void parseWithNonKeyFileWillThrowException() throws IOException { - Path path = this.fileWriter.writeFile("text.pem", "plain text"); - assertThatIllegalStateException().isThrownBy(() -> PrivateKeyParser.parse(path)) - .withMessageContaining(path.toString()); - } - - @Test - void parseWithInvalidPathWillThrowException() throws URISyntaxException { - Path path = Paths.get(new URI("file:///bad/path/key.pem")); - assertThatIllegalStateException().isThrownBy(() -> PrivateKeyParser.parse(path)) - .withMessageContaining(path.toString()); - } - - @Nested - class DerEncoderTests { - - @Test - void codeLengthBytesShort() throws Exception { - DerEncoder encoder = new DerEncoder(); - encoder.codeLengthBytes(0, new byte[127]); - assertThat(encoder.toByteArray()).startsWith(0x0, 0x7F); - } - - @Test - void codeLengthBytesMedium() throws Exception { - DerEncoder encoder = new DerEncoder(); - encoder.codeLengthBytes(0, new byte[130]); - assertThat(encoder.toByteArray()).startsWith(0x0, 0x81, 0x82); - } - - @Test - void codeLengthBytesLong() throws Exception { - DerEncoder encoder = new DerEncoder(); - encoder.codeLengthBytes(0, new byte[258]); - assertThat(encoder.toByteArray()).startsWith(0x0, 0x82, 0x01, 0x02); - } - - } - -} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/SslSource.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/SslSource.java new file mode 100644 index 0000000000..be77d82508 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/SslSource.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Utility to compare SSL source code. + * + * @author Phillip Webb + */ +final class SslSource { + + private static final Path BUILDPACK_LOCATION = Path + .of("src/main/java/org/springframework/boot/buildpack/platform/docker/ssl"); + + private static final Path SPRINGBOOT_LOCATION = Path + .of("../../spring-boot/src/main/java/org/springframework/boot/ssl/pem"); + + private SslSource() { + } + + static String loadBuildpackVersion(String name) throws IOException { + return load(BUILDPACK_LOCATION.resolve(name)); + } + + static String loadSpringBootVersion(String name) throws IOException { + return load(SPRINGBOOT_LOCATION.resolve(name)); + } + + private static String load(Path path) throws IOException { + String code = Files.readString(path); + int firstBrace = code.indexOf("{"); + int lastBrace = code.lastIndexOf("}"); + return code.substring(firstBrace, lastBrace + 1); + } + +}