com.seeq.link.agent.DefaultCertificateService Maven / Gradle / Ivy
package com.seeq.link.agent;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import org.apache.commons.io.FileUtils;
import com.seeq.link.sdk.interfaces.CertificateService;
import com.seeq.utilities.process.OperatingSystem;
import com.seeq.utilities.process.ProcessSpawn;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DefaultCertificateService implements CertificateService {
private final List trustedCertificatesPaths;
private final Path untrustedCertificatesPath;
private volatile KeyStore keyStore;
private volatile X509TrustManager trustManager;
public DefaultCertificateService() {
this(new ArrayList<>(), null);
}
public DefaultCertificateService(Path trustedCertificatesPath) {
this(Collections.singletonList(trustedCertificatesPath), null);
}
public DefaultCertificateService(List trustedCertificatesPaths, Path untrustedCertificatesPath) {
this.trustedCertificatesPaths = trustedCertificatesPaths;
this.untrustedCertificatesPath = untrustedCertificatesPath;
this.keyStore = null;
this.trustManager = null;
}
@Override
public String addUntrustedCertificatesToDisk(String url) {
List certificates = downloadCertificatesFromSite(url);
List entries = new ArrayList<>();
String messagePreamble = String.format("Encountered SSL error connecting to '%s', probably because of an " +
"untrusted SSL certificate.", url);
for (X509Certificate certificate : certificates) {
String certName = getCertName(certificate);
try {
entries.add(String.format("%s\n%s", certName, convertCertificateToPEM(certificate)));
} catch (CertificateEncodingException e) {
return String.format("%s Seeq attempted to download the untrusted certificate on the " +
"Agent machine, but encountered an error converting certificate '%s' to PEM " +
"format:\n%s",
messagePreamble, certName, e);
}
}
String content = String.join("\n\n", entries);
if (!this.untrustedCertificatesPath.toFile().isDirectory()) {
return String.format("%s Seeq attempted to write the untrusted certificate to '%s' on the Agent " +
"machine, but the path does not exist or is not a directory.",
messagePreamble, this.untrustedCertificatesPath);
}
String fileName = String.format("%s.pem", getCleansedUrlMoniker(url));
Path filePath = this.untrustedCertificatesPath.resolve(fileName);
try {
Files.write(filePath, content.getBytes());
} catch (IOException e) {
return String.format("%s Seeq attempted to write the untrusted certificate to '%s' on the Agent " +
"machine, but encountered an error:\n%s",
messagePreamble, filePath, e);
}
// We initialize here in case the user moved the certificate from untrusted to trusted, so that the next time
// this is called, we'll pick it up.
this.initialize();
return String.format("%s The untrusted certificate has been written to '%s' on the " +
"Agent machine, you can move it to the trusted folder if appropriate. See https://telemetry" +
".seeq.com/support-link/kb/latest/cloud/troubleshooting-secure-configuration-ssl-tls for " +
"more information.",
messagePreamble, filePath);
}
private static String getCleansedUrlMoniker(String url) {
String cleansedUrlMoniker = url;
try {
URL urlObj = new URL(url);
cleansedUrlMoniker = urlObj.getHost() + (urlObj.getPort() != -1 ? ":" + urlObj.getPort() : "");
} catch (Exception e) {
LOG.error("Error parsing URL \"{}\"", url, e);
}
cleansedUrlMoniker = cleansedUrlMoniker.replaceAll("[^a-zA-Z0-9]", "_");
return cleansedUrlMoniker;
}
public void initialize() {
synchronized (this) {
this.keyStore = this.createKeyStore();
this.trustManager = this.createTrustManager(this.keyStore);
}
}
@Override
public KeyStore getKeyStore() {
return this.keyStore;
}
@Override
public X509TrustManager getTrustManager() {
return this.trustManager;
}
private static Path getCacertsPath() {
return Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts");
}
public static List getJvmRootCertificates() {
// We always load from the cacerts file so that we can pick up new certificates that were added via "keytool"
// without requiring a restart.
//
// In testing Ignition 8.1, it was found that the cacerts file is modified automatically based on what the
// user may have put in the /certificates/supplemental folder. It was confirmed that this code
// will therefore pick up any of those supplemental certificates.
try {
Path path = getCacertsPath();
FileInputStream is = new FileInputStream(path.toFile());
KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
String password = "changeit";
keystore.load(is, password.toCharArray());
return Collections.list(keystore.aliases()).stream()
.map(alias -> {
try {
return keystore.getCertificate(alias);
} catch (KeyStoreException e) {
LOG.error("Error retrieving JVM certificate '{}' from '{}'",
alias, path, e);
return null;
}
})
.filter(certificate -> certificate instanceof X509Certificate)
.map(certificate -> (X509Certificate) certificate)
.collect(Collectors.toList());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static List getWindowsCertificates() {
if (!OperatingSystem.isWindows()) {
return Collections.emptyList();
}
List certificates = new ArrayList<>();
String[] windowsKeyStoreNames = new String[] {
"Windows-ROOT",
"Windows-MY",
"Windows-ROOT-LOCALMACHINE",
"Windows-MY-LOCALMACHINE"
};
boolean usePowershell = false;
for (String windowsKeyStoreName : windowsKeyStoreNames) {
try {
KeyStore windowsKeyStore = KeyStore.getInstance(windowsKeyStoreName);
windowsKeyStore.load(null, null);
Collections.list(windowsKeyStore.aliases()).stream()
.map(alias -> {
try {
return windowsKeyStore.getCertificate(alias);
} catch (KeyStoreException e) {
LOG.error("Error retrieving Windows certificate '{}' from '{}' key store",
alias, windowsKeyStoreName, e);
return null;
}
})
.filter(certificate -> certificate instanceof X509Certificate)
.forEach(certificate -> certificates.add((X509Certificate) certificate));
} catch (KeyStoreException e) {
if (windowsKeyStoreName.contains("-LOCALMACHINE") && e.getMessage().contains("not found")) {
// This happens because https://bugs.openjdk.org/browse/JDK-6782021 may not have been merged into
// the version of the JVM we're using. (This happens with Ignition, since Ignition is the thing
// dictating what JVM is used.)
usePowershell = true;
} else {
LOG.info("Error retrieving Windows certificates from '{}' key store", windowsKeyStoreName, e);
}
} catch (IOException e) {
if (e.getCause() instanceof KeyStoreException &&
e.getCause().getMessage().contains("Access is denied")) {
// This happens because of https://bugs.openjdk.org/browse/JDK-8313367.
usePowershell = true;
} else {
LOG.info("Error retrieving Windows certificates from '{}' key store", windowsKeyStoreName, e);
}
} catch (Throwable e) {
LOG.info("Error retrieving Windows certificates from '{}' key store", windowsKeyStoreName, e);
}
}
if (usePowershell) {
certificates.addAll(getWindowsLocalMachineCertificatesViaPowershell("root"));
certificates.addAll(getWindowsLocalMachineCertificatesViaPowershell("my"));
}
return certificates;
}
private static List getWindowsLocalMachineCertificatesViaPowershell(String certType) {
// Theoretically we could use KeyStore.getInstance("Windows-ROOT-LOCALMACHINE") and
// KeyStore.getInstance("Windows-MY-LOCALMACHINE") to accomplish this, which were added as part of
// https://bugs.openjdk.org/browse/JDK-6782021. Unfortunately, we'll get an Access Denied error due to
// https://bugs.openjdk.org/browse/JDK-8313367. Plus, none of it was backported to Java 8 anyway, and we
// would ideally have identical behavior for Ignition v7 which runs on Java 8.
// So instead of "native" Java functionality we use a Powershell script to dump out all the certificates and
// read them from disk.
Path tempFolder;
List certificates = new ArrayList<>();
try {
tempFolder = Files.createTempDirectory("seeq-link-getWindowsLocalMachineCertificates");
} catch (Throwable e) {
LOG.error("Error creating temporary folder for Windows certificates", e);
return Collections.emptyList();
}
try {
String powershellScript = String.format("dir cert:\\localmachine\\%s | Foreach-Object { [system.IO" +
".file]::WriteAllBytes(\".\\$($_.Thumbprint).cer\", ($_.Export('CERT', 'secret')) ) }", certType);
Path scriptLocation = tempFolder.resolve("getWindowsLocalMachineCertificates.ps1");
Files.write(scriptLocation, powershellScript.getBytes());
ProcessSpawn processSpawn = new ProcessSpawn(
"powershell.exe", "-ExecutionPolicy", "Bypass", "-File", scriptLocation.toString());
processSpawn.directory(tempFolder.toFile());
processSpawn.setCaptureTextEnabled(true);
processSpawn.start();
processSpawn.waitFor();
if (processSpawn.getExitValue() != 0) {
LOG.warn("Error retrieving local machine Windows certificates for type '{}': Powershell script " +
"returned {} exit code.\nOutput:\n{}\nScript contents:\n{}",
certType, processSpawn.getExitValue(),
processSpawn.getOutputString() + "\n" + processSpawn.getErrorString(), powershellScript);
return Collections.emptyList();
}
Collection cerFiles = FileUtils.listFiles(tempFolder.toFile(), new String[] { "cer" }, false);
for (File cerFile : cerFiles) {
try (FileInputStream fis = new FileInputStream(cerFile)) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Certificate cert = cf.generateCertificate(fis);
if (cert instanceof X509Certificate) {
certificates.add((X509Certificate) cert);
}
} catch (Exception e) {
LOG.warn("Error reading Windows certificate from '{}'", cerFile, e);
}
}
return certificates;
} catch (Throwable e) {
LOG.error("Error retrieving local machine Windows certificates", e);
return Collections.emptyList();
} finally {
if (tempFolder != null) {
try {
FileUtils.deleteDirectory(tempFolder.toFile());
} catch (Exception e) {
LOG.error("Error deleting temporary folder for Windows certificates", e);
}
}
}
}
private static void addToKeyStore(KeyStore keyStore, int n, String origin, X509Certificate certificate)
throws KeyStoreException {
keyStore.setCertificateEntry(String.format("%03d %s %s", n, origin, getCertName(certificate)), certificate);
}
private KeyStore createKeyStore() {
try {
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
int n = 0;
// JVM root certificates
List jvmRootCertificates = getJvmRootCertificates();
LOG.info("Found {} certificates in '{}'", jvmRootCertificates.size(), getCacertsPath());
for (X509Certificate certificate : jvmRootCertificates) {
try {
addToKeyStore(keyStore, ++n, "cacerts", certificate);
} catch (KeyStoreException e) {
throw new RuntimeException(String.format("Error adding JVM root certificate '%s' to key store",
getCertName(certificate)), e);
}
}
// Windows root certificates
List windowsRootCertificates = getWindowsCertificates();
LOG.info("Found {} certificates in Windows trust stores (ROOT, MY, ROOT-LOCALMACHINE, MY-LOCALMACHINE)",
windowsRootCertificates.size());
for (X509Certificate certificate : windowsRootCertificates) {
try {
addToKeyStore(keyStore, ++n, "windows", certificate);
} catch (KeyStoreException e) {
throw new RuntimeException(String.format("Error adding Windows certificate '%s' to key store",
getCertName(certificate)), e);
}
}
// User-trusted certificates from the Agent's "global_folder/keys/trusted" folder
for (String pemCertificate : this.loadTrustedCertificatesFromPaths()) {
X509Certificate certificate = parsePEM(pemCertificate);
LOG.info("Using trusted certificate from disk {}", getCertName(certificate));
addToKeyStore(keyStore, ++n, "seeq-trusted-folder", certificate);
}
return keyStore;
} catch (Exception e) {
throw new RuntimeException("Error creating key store from certificate configs", e);
}
}
private X509TrustManager createTrustManager(KeyStore keyStore) {
try {
// Create a TrustManager that trusts the certificate in the KeyStore
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(keyStore);
TrustManager[] trustManagers = tmf.getTrustManagers();
if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers));
}
return (X509TrustManager) trustManagers[0];
} catch (Exception e) {
throw new RuntimeException("Error creating trust manager from certificate configs", e);
}
}
static List downloadCertificatesFromSite(String url) {
List certificates = new ArrayList<>();
try {
URL urlObj = new URL(url);
// Create a trust manager that accepts all certificates
TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
} };
HostnameVerifier allHostsValid = (hostname, session) -> true;
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
// Open a connection to the HTTPS site using the private SSL context
HttpsURLConnection connection = (HttpsURLConnection) urlObj.openConnection();
connection.setSSLSocketFactory(sslContext.getSocketFactory());
connection.setHostnameVerifier(allHostsValid);
// This call causes the connection to be made
connection.getResponseCode();
Certificate[] serverCertificates = connection.getServerCertificates();
for (Certificate certificate : serverCertificates) {
if (certificate instanceof X509Certificate) {
certificates.add((X509Certificate) certificate);
} else {
LOG.warn("Certificate from {} is not an X509Certificate: {}", url,
certificate.getClass().getName());
}
}
} catch (Exception e) {
throw new RuntimeException(
String.format("Error downloading untrusted certificates from '%s'", url), e);
}
return certificates;
}
private List loadTrustedCertificatesFromPaths() {
List certificates = new ArrayList<>();
for (Path trustedCertificatesPath : this.trustedCertificatesPaths) {
if (!Files.exists(trustedCertificatesPath)) {
LOG.warn("Trusted certificates path \"{}\" does not exist", trustedCertificatesPath);
continue;
}
if (trustedCertificatesPath.toFile().isDirectory()) {
try (Stream trustedFiles = Files.list(trustedCertificatesPath)) {
trustedFiles.forEach(
trustedFile -> certificates.addAll(loadAndVerifyTrustedCertificates(trustedFile)));
} catch (Exception e) {
LOG.error("Could not load trusted certificates from \"{}\":", trustedCertificatesPath, e);
}
} else {
certificates.addAll(loadAndVerifyTrustedCertificates(trustedCertificatesPath));
}
}
return certificates;
}
/**
* Verifies the certificate chain present in a file.
*
* The certificate PEM file should contain all the certificates necessary to validate the entire chain in the
* standard certificate order (most specific/server certificate to least specific), so the certificates can be
* validated in order:
*
* If the server certificate is issued by an official Certificate Authority (CA):
* Server Certificate
* CA Intermediate Certificate 1
* CA Intermediate Certificate 2
*
* If the server certificate is self-issued (meaning an internal/non-official Certificate Authority was used):
* Server Certificate
* CA Intermediate Certificate 1
* CA Certificate
*
* If the server certificate is self-signed (meaning no Certificate Authority was used):
* Server Certificate
*
* @param certificates
* The certificates to verify
* @return true if the certificate is signed by a trusted CA, false if it is self-signed or self-issued
*/
static boolean verifyCertificateChain(List certificates) {
CertificateFactory certificateFactory;
TrustManagerFactory trustManagerFactory;
try {
certificateFactory = CertificateFactory.getInstance("X.509");
trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init((KeyStore) null);
} catch (Exception e) {
throw new RuntimeException("Error retrieving X.509 CertificateFactory and TrustManagerFactory", e);
}
LOG.info("Number of certificates found in file: {}", certificates.size());
if (certificates.isEmpty()) {
return false;
}
if (certificates.size() > 1) {
for (int i = 0; i < certificates.size() - 1; i++) {
String currentPemCert = certificates.get(i);
String nextPemCert = certificates.get(i + 1);
X509Certificate currentCert;
try {
currentCert = (X509Certificate) certificateFactory
.generateCertificate(new ByteArrayInputStream(currentPemCert.getBytes()));
} catch (Exception e) {
throw new RuntimeException(
String.format("Error generating X.509 certificate from PEM certificate:\n%s",
currentPemCert), e);
}
X509Certificate nextCert;
try {
nextCert = (X509Certificate) certificateFactory
.generateCertificate(new ByteArrayInputStream(nextPemCert.getBytes()));
} catch (Exception e) {
throw new RuntimeException(
String.format("Error generating X.509 certificate from PEM certificate:\n%s",
nextPemCert), e);
}
LOG.info("Verifying #{}: '{}' (valid until {}), issued by '{}' against '{}' (valid until {})...",
i, getCertName(currentCert), currentCert.getNotAfter(),
currentCert.getIssuerX500Principal(),
getCertName(nextCert), nextCert.getNotAfter());
try {
currentCert.verify(nextCert.getPublicKey());
} catch (Exception e) {
throw new RuntimeException(
String.format("Error verifying certificate chain:\n%s\n%s", currentPemCert,
nextPemCert),
e);
}
}
}
// Verify the last cert in the PEM file against the CAs in the default trust store
String lastPemCert = certificates.get(certificates.size() - 1); // Last cert in the PEM file
X509Certificate lastCert = parsePEM(lastPemCert);
LOG.info("Verifying #{}: '{}' (valid until {}, issued by '{}') against CA trust store...",
certificates.size() - 1,
getCertName(lastCert), lastCert.getNotAfter(), lastCert.getIssuerX500Principal());
TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
boolean certIsSignedByOfficialCertAuthority = false;
for (TrustManager trustManager : trustManagers) {
X509TrustManager x509TrustManager = (X509TrustManager) trustManager;
for (X509Certificate caCert : x509TrustManager.getAcceptedIssuers()) {
try {
lastCert.verify(caCert.getPublicKey());
LOG.info("Successfully verified '{}' against CA '{}'/'{}'",
getCertName(lastCert), getCertName(caCert), caCert.getIssuerX500Principal().getName());
certIsSignedByOfficialCertAuthority = true;
} catch (Exception e) {
// Ignore any verification failure - it was most likely the wrong CA certificate
}
}
}
if (certIsSignedByOfficialCertAuthority) {
LOG.info("Certificate chain verification complete. Certificate is signed by an official CA.");
} else {
LOG.info("Certificate chain verification complete. Certificate is NOT signed by an official CA " +
"(it's self-issued or self-signed).");
}
return certIsSignedByOfficialCertAuthority;
}
static String getCertName(X509Certificate cert) {
return cert.getSubjectX500Principal().getName("RFC2253");
}
private static X509Certificate parsePEM(String pem) {
try {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate) certificateFactory.generateCertificate(
new ByteArrayInputStream(pem.getBytes()));
} catch (Exception e) {
throw new RuntimeException(String.format("Error parsing PEM certificate\n%s", pem), e);
}
}
private static List loadAndVerifyTrustedCertificates(Path trustedFile) {
LOG.info("Loading and verifying trusted certificates from \"{}\"...", trustedFile);
List certificates = loadCertificatesFromPath(trustedFile);
verifyCertificateChain(certificates);
return certificates;
}
static List loadCertificatesFromPath(Path trustedFile) {
List certificates = new ArrayList<>();
try {
String contents = new String(Files.readAllBytes(trustedFile));
Pattern pattern = Pattern.compile("-+BEGIN\\s+CERTIFICATE-+(.*?)-+END\\s+CERTIFICATE-+", Pattern.DOTALL);
Matcher matcher = pattern.matcher(contents);
while (matcher.find()) {
certificates.add(matcher.group(0));
}
} catch (Exception e) {
LOG.error("Could not load trusted certificates from \"{}\":", trustedFile, e);
}
return certificates;
}
static String convertCertificateToPEM(X509Certificate certificate) throws CertificateEncodingException {
byte[] certBytes = certificate.getEncoded();
String encoded = Base64.getEncoder().encodeToString(certBytes);
StringBuilder pemCertificate = new StringBuilder();
pemCertificate.append("-----BEGIN CERTIFICATE-----\n");
// Break the Base64 encoded string into lines of 64 characters
for (int i = 0; i < encoded.length(); i += 64) {
pemCertificate.append(encoded, i, Math.min(i + 64, encoded.length())).append('\n');
}
pemCertificate.append("-----END CERTIFICATE-----\n");
return pemCertificate.toString();
}
/**
* Outputs the default trusted CA certificates to the log. This is only meant for debugging CA certificate issues.
*/
@SuppressWarnings("unused")
public static void logDefaultTruststore() {
TrustManagerFactory tmf;
try {
tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null);
} catch (Exception e) {
LOG.error("Error getting default TrustManagerFactory", e);
return;
}
for (TrustManager m : tmf.getTrustManagers()) {
X509TrustManager mgr = (X509TrustManager) m;
for (X509Certificate c : mgr.getAcceptedIssuers()) {
LOG.debug("Trusted CA Certificate: {}", c.getSubjectX500Principal());
}
}
}
}