Add certificate authentication support in SDK (#2269)
Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,6 @@ public class Controller {
|
||||
// (target) security token
|
||||
@Nullable
|
||||
private String securityToken;
|
||||
@Nullable
|
||||
private Certificate certificate;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user