Add certificate authentication support in SDK (#2269)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-02-12 13:53:08 +02:00
committed by GitHub
parent 1e4e45f7bb
commit 6675163a5d
13 changed files with 380 additions and 61 deletions

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.sdk;
import java.io.IOException;
import java.io.OutputStream;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.Objects;
import lombok.Data;
@Data
public class Certificate {
private final KeyPair keyPair;
private final X509Certificate[] certificateChain;
// create holder for the certificate - key pair and the certificate chain
public Certificate(final KeyPair keyPair, final X509Certificate[] certificateChain) {
Objects.requireNonNull(keyPair, "keyPair must not be null");
Objects.requireNonNull(certificateChain, "certificateChain must not be null");
if (certificateChain.length == 0) {
throw new IllegalArgumentException("certificateChain must not be empty");
}
this.keyPair = keyPair;
this.certificateChain = certificateChain;
}
public KeyStore toKeyStore(final String pass) throws KeyStoreException {
try {
final KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null); // init
keyStore.setKeyEntry("alias", keyPair.getPrivate(), pass.toCharArray(), certificateChain);
return keyStore;
} catch (final NoSuchAlgorithmException | CertificateException | IOException e) {
throw new KeyStoreException(e);
}
}
public void writeToOS(final String pass, final OutputStream os) throws KeyStoreException {
try {
toKeyStore(pass).store(os, pass.toCharArray());
} catch (final NoSuchAlgorithmException | CertificateException | IOException e) {
throw new KeyStoreException(e);
}
}
public StringBuilder printPem(final StringBuilder sb) throws CertificateException {
for (final X509Certificate certificate : certificateChain) {
sb.append("-----BEGIN CERTIFICATE-----\n");
sb.append(toPem(certificate.getEncoded()));
sb.append("-----END CERTIFICATE-----\n");
}
sb.append('\n');
sb.append("-----BEGIN PRIVATE KEY-----\n");
sb.append(toPem(keyPair.getPrivate().getEncoded()));
sb.append("-----END PRIVATE KEY-----\n");
return sb;
}
private static String toPem(final byte[] ba) {
final String base64 = Base64.getEncoder().encodeToString(ba);
final StringBuilder formatted = new StringBuilder();
for (int off = 0, end; (end = Math.min(off + 64, base64.length())) >= 0; off = end) {
formatted.append(base64, off, end).append("\n");
}
return formatted.toString();
}
}

View File

@@ -24,4 +24,6 @@ public class Controller {
// (target) security token
@Nullable
private String securityToken;
@Nullable
private Certificate certificate;
}

View File

@@ -21,15 +21,28 @@ import java.lang.reflect.Proxy;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Random;
import java.util.UUID;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManagerFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.Client;
import feign.Contract;
@@ -58,29 +71,22 @@ public class HawkbitClient {
private static final String AUTHORIZATION = "Authorization";
public static final BiFunction<Tenant, Controller, RequestInterceptor> DEFAULT_REQUEST_INTERCEPTOR_FN =
(tenant, controller) ->
controller == null
? template ->
template.header(
AUTHORIZATION,
"Basic " +
Base64.getEncoder()
.encodeToString(
(Objects.requireNonNull(tenant.getUsername(), "User is null!") +
":" +
Objects.requireNonNull(tenant.getPassword(),
"Password is not available!"))
.getBytes(StandardCharsets.ISO_8859_1)))
: template -> {
if (ObjectUtils.isEmpty(tenant.getGatewayToken())) {
if (!ObjectUtils.isEmpty(controller.getSecurityToken())) {
template.header(AUTHORIZATION, "TargetToken " + controller.getSecurityToken());
} // else do not send authentication
} else {
template.header(AUTHORIZATION, "GatewayToken " + tenant.getGatewayToken());
}
};
(tenant, controller) -> controller == null
? template ->
template.header(
AUTHORIZATION,
"Basic " + Base64.getEncoder().encodeToString(
(Objects.requireNonNull(tenant.getUsername(), "User is null!") +
":" +
Objects.requireNonNull(tenant.getPassword(),"Password is not available!"))
.getBytes(StandardCharsets.ISO_8859_1)))
: template -> {
if (!ObjectUtils.isEmpty(tenant.getGatewayToken())) {
template.header(AUTHORIZATION, "GatewayToken " + tenant.getGatewayToken());
} else if (!ObjectUtils.isEmpty(controller.getSecurityToken())) {
template.header(AUTHORIZATION, "TargetToken " + controller.getSecurityToken());
} // else do not send authentication, no auth or certificate based
};
private static final ErrorDecoder DEFAULT_ERROR_DECODER_0 = new ErrorDecoder.Default();
public static final ErrorDecoder DEFAULT_ERROR_DECODER = (methodKey, response) -> {
final Exception e = DEFAULT_ERROR_DECODER_0.decode(methodKey, response);
@@ -144,6 +150,19 @@ public class HawkbitClient {
}
private <T> T service0(final Class<T> serviceType, final Tenant tenant, final Controller controller) {
final Client client;
if (controller != null && controller.getCertificate() != null && hawkBitServer.getDdiUrl().startsWith("https://")) {
// mTLS could be requested
try {
client = mTlsClient(controller.getCertificate(), tenant);
} catch (final RuntimeException | Error e) {
throw e;
} catch (final Exception e) {
throw new IllegalStateException("Failed to create mTLS client", e);
}
} else {
client = this.client;
}
return Feign.builder().client(client)
.encoder(encoder)
.decoder(decoder)
@@ -312,4 +331,33 @@ public class HawkbitClient {
}
return null;
}
private static final String KEYSTORE_PASSWORD;
static {
final Random random = new SecureRandom();
final byte[] bytes = new byte[16];
random.nextBytes(bytes);
KEYSTORE_PASSWORD = Base64.getEncoder().encodeToString(bytes);
}
private static Client mTlsClient(final Certificate certificate, final Tenant tenant)
throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException, CertificateException, IOException,
KeyManagementException {
final KeyStore clientKeyStore = certificate.toKeyStore(KEYSTORE_PASSWORD);
final KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD.toCharArray());
// Truststore
final TrustManagerFactory trustManagerFactory;
if (tenant.getDdiCertificate() == null) {
trustManagerFactory = null;
} else {
final KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
trustStore.setEntry("alias", new KeyStore.TrustedCertificateEntry(tenant.getDdiCertificate()), null);
trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(trustStore);
}
final SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory == null ? null : trustManagerFactory.getTrustManagers(), null);
return new Client.Default(sslContext.getSocketFactory(), null);
}
}

View File

@@ -9,8 +9,11 @@
*/
package org.eclipse.hawkbit.sdk;
import java.security.cert.Certificate;
import lombok.Data;
import lombok.ToString;
import org.eclipse.hawkbit.sdk.ca.CA;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
@@ -33,6 +36,16 @@ public class Tenant {
// gateway token
@Nullable
private String gatewayToken;
// gateway token
@Nullable
private String[] certificateFingerprints;
// the tenant DDI server certificate - it shall be trusted by controllers connecting via HTTPS
@Nullable
private Certificate ddiCertificate;
// Certificate Authority for the tenant that is used to sign the target certificates. It shall be trusted by the DDI server
@Nullable
private CA ddiCA;
// amqp settings (if DMF is used)
@Nullable

View File

@@ -0,0 +1,136 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.sdk.ca;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.Objects;
import javax.security.auth.x500.X500Principal;
import lombok.Data;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.eclipse.hawkbit.sdk.Certificate;
@Data
public class CA {
public static final String DEFAULT_CA_DN = "CN=CA, O=hawkBit, L=Sofia, C=BG";
public static final long DEFAULT_NOT_BEFORE_DAYS_OFFSET = 1;
public static final long DEFAULT_NOT_AFTER_DAYS_OFFSET = 30;
private static final String SHA_256_WITH_RSA_ENCRYPTION = "SHA256WithRSAEncryption";
private final Certificate certificate;
private long nextSerial = System.currentTimeMillis();
// creates a self-signed CA with defaults
public CA() throws CertificateException {
this(null, null, null);
}
// creates a self-signed CA
public CA(final String caDN, final Date notBefore, final Date notAfter) throws CertificateException {
this(selfSign(caDN, notBefore, notAfter));
}
// create a CA with a key and certificate chain
public CA(final Certificate certificate) {
this(certificate, 0);
}
// create a CA with a key and certificate chain
public CA(final Certificate certificate, final long nextSerial) {
this.certificate = certificate;
this.nextSerial = nextSerial;
}
// generate key and issue a certificate with defaults
public Certificate issue(final String subject) throws CertificateException {
return issue(subject, null, null);
}
// generate key and issue a certificate
public Certificate issue(final String subject, final Date notBefore, final Date notAfter) throws CertificateException {
Objects.requireNonNull(subject);
try {
final KeyPair keyPair = genKey();
final X509Certificate[] certificateChain = certificate.getCertificateChain();
final ContentSigner signer = new JcaContentSignerBuilder(SHA_256_WITH_RSA_ENCRYPTION).build(certificate.getKeyPair().getPrivate());
final X509v3CertificateBuilder caCertBuilder = new JcaX509v3CertificateBuilder(
certificateChain[0].getSubjectX500Principal(),
BigInteger.valueOf(nextSerial++), notBefore(notBefore), notAfter(notAfter), new X500Principal(subject),
keyPair.getPublic());
final X509Certificate[] subjectCertificateChain = new X509Certificate[certificateChain.length + 1];
certificateChain[0] = new JcaX509CertificateConverter().getCertificate(caCertBuilder.build(signer));
System.arraycopy(certificateChain, 0, subjectCertificateChain, 1, certificateChain.length);
return new Certificate(keyPair, subjectCertificateChain);
} catch (final NoSuchAlgorithmException | OperatorCreationException e) {
throw new CertificateException(e);
}
}
public String getFingerprint() {
try {
final X509Certificate[] certificateChain = certificate.getCertificateChain();
return toHex(MessageDigest.getInstance("SHA-256").digest(certificateChain[certificateChain.length - 1].getEncoded()));
} catch (final NoSuchAlgorithmException | CertificateEncodingException e) {
throw new IllegalArgumentException(e);
}
}
private static String toHex(final byte[] bytes) {
final StringBuilder sb = new StringBuilder();
for (final byte b : bytes) {
sb.append(String.format("%02x", b)).append(':');
}
sb.deleteCharAt(sb.length() - 1);
return sb.toString();
}
private static Certificate selfSign(final String caDN, final Date notBefore, final Date notAfter) throws CertificateException {
try {
final KeyPair keyPair = genKey();
final X500Principal caPrincipal = new X500Principal(caDN == null ? DEFAULT_CA_DN : caDN);
final ContentSigner selfSigner = new JcaContentSignerBuilder(SHA_256_WITH_RSA_ENCRYPTION).build(keyPair.getPrivate());
final X509v3CertificateBuilder caCertBuilder = new JcaX509v3CertificateBuilder(
caPrincipal, BigInteger.valueOf(0L), notBefore(notBefore), notAfter(notAfter), caPrincipal, keyPair.getPublic());
return new Certificate(keyPair, new X509Certificate[] { new JcaX509CertificateConverter().getCertificate(caCertBuilder.build(selfSigner)) });
} catch (final NoSuchAlgorithmException | OperatorCreationException e) {
throw new CertificateException(e);
}
}
private static KeyPair genKey() throws NoSuchAlgorithmException {
final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
}
private static Date notBefore(final Date notBefore) {
return notBefore == null ? new Date(System.currentTimeMillis() - DEFAULT_NOT_BEFORE_DAYS_OFFSET * 24 * 3600_000L) : notBefore;
}
private static Date notAfter(final Date notAfter) {
return notAfter == null ? new Date(System.currentTimeMillis() + DEFAULT_NOT_AFTER_DAYS_OFFSET * 24 * 3600_000L) : notAfter;
}
}