diff --git a/build.gradle.kts b/build.gradle.kts index 2dd2e80f..0c0360e5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,7 +26,7 @@ licenseReport { allprojects { group = "de.fhg.aisec.ids" - version = "7.2.2" + version = "7.3.0" val versionRegex = ".*((rc|beta|alpha)-?[0-9]*|-b[0-9.]+)$".toRegex(RegexOption.IGNORE_CASE) diff --git a/examples/src/main/resources/etc/application.yml b/examples/src/main/resources/etc/application.yml index 98847bdb..4a91d385 100644 --- a/examples/src/main/resources/etc/application.yml +++ b/examples/src/main/resources/etc/application.yml @@ -4,6 +4,10 @@ logging: de.fhg.aisec.ids.idscp2: trace de.fhg.aisec.ids.camel: trace +server: + error: + include-message: always + ids-multipart: daps-bean-name: rootDaps diff --git a/examples/trusted-connector-examples_7.3.0.zip b/examples/trusted-connector-examples_7.3.0.zip new file mode 100644 index 00000000..79e5a78d Binary files /dev/null and b/examples/trusted-connector-examples_7.3.0.zip differ diff --git a/ids-webconsole/src/main/angular/src/app/app.module.ts b/ids-webconsole/src/main/angular/src/app/app.module.ts index ef7cde9a..1153869d 100644 --- a/ids-webconsole/src/main/angular/src/app/app.module.ts +++ b/ids-webconsole/src/main/angular/src/app/app.module.ts @@ -54,6 +54,8 @@ import { UserService } from './users/user.service'; import { UserCardComponent } from './users/user-card.component'; import { NewIdentityESTComponent } from './keycerts/identitynewest.component'; import { ESTService } from './keycerts/est-service'; +import { RenewIdentityESTComponent } from './keycerts/identityrenewest.component'; +import { SnackbarComponent } from './keycerts/snackbar.component'; @NgModule({ declarations: [ AppComponent, @@ -88,7 +90,9 @@ import { ESTService } from './keycerts/est-service'; DetailUserComponent, UserCardComponent, UsersComponent, - NewIdentityESTComponent + NewIdentityESTComponent, + RenewIdentityESTComponent, + SnackbarComponent ], bootstrap: [ AppComponent diff --git a/ids-webconsole/src/main/angular/src/app/app.routing.ts b/ids-webconsole/src/main/angular/src/app/app.routing.ts index b39ced6a..33a15205 100644 --- a/ids-webconsole/src/main/angular/src/app/app.routing.ts +++ b/ids-webconsole/src/main/angular/src/app/app.routing.ts @@ -18,6 +18,7 @@ import { RouteeditorComponent } from './routes/routeeditor/routeeditor.component import { UsersComponent } from './users/users.component'; import { NewUserComponent } from './users/usernew.component'; import { DetailUserComponent } from './users/userdetail.component'; +import { RenewIdentityESTComponent } from './keycerts/identityrenewest.component'; import { RoutesComponent } from './routes/routes.component'; import { NewIdentityESTComponent } from './keycerts/identitynewest.component'; @@ -43,7 +44,8 @@ const appRoutes: Routes = [ { path: 'usernew', component: NewUserComponent, canActivate: guards }, { path: 'userdetail', component: DetailUserComponent, canActivate: guards }, { path: 'certificates', component: KeycertsComponent, canActivate: guards }, - { path: 'identitynewest', component: NewIdentityESTComponent, canActivate: guards } + { path: 'identitynewest', component: NewIdentityESTComponent, canActivate: guards }, + { path: 'identityrenewest/:alias', component: RenewIdentityESTComponent, canActivate: guards } ] }, // Pages using the "login" layout (centered full page without sidebar) diff --git a/ids-webconsole/src/main/angular/src/app/keycerts/certificate-card.component.html b/ids-webconsole/src/main/angular/src/app/keycerts/certificate-card.component.html index 2f204320..05639c9b 100644 --- a/ids-webconsole/src/main/angular/src/app/keycerts/certificate-card.component.html +++ b/ids-webconsole/src/main/angular/src/app/keycerts/certificate-card.component.html @@ -7,10 +7,13 @@ {{ certificate.subjectDistinguishedName }} - +
+ + refresh + delete - +
diff --git a/ids-webconsole/src/main/angular/src/app/keycerts/certificate-card.component.ts b/ids-webconsole/src/main/angular/src/app/keycerts/certificate-card.component.ts index 5f463eaa..7077411e 100644 --- a/ids-webconsole/src/main/angular/src/app/keycerts/certificate-card.component.ts +++ b/ids-webconsole/src/main/angular/src/app/keycerts/certificate-card.component.ts @@ -16,6 +16,7 @@ export class CertificateCardComponent implements OnInit { @Input() public certificates: Certificate[]; @Input() public trusts: Certificate[]; @Input() private readonly onDeleteCallback: (alias: string) => void; + @Input() private readonly onRenewCallback: (alias: string) => void = null; public result: string; constructor(private readonly confirmService: ConfirmService) { @@ -29,6 +30,13 @@ export class CertificateCardComponent implements OnInit { return item.subjectS + item.subjectCN + item.subjectOU + item.subjectO + item.subjectL + item.subjectC; } + public onRenew(alias: string): void { + // Sanity check + if (this.onRenewCallback) { + this.onRenewCallback(alias); + } + } + public async onDelete(alias: string): Promise { return this.confirmService.activate('Are you sure that you want to delete this item?') .then(res => { diff --git a/ids-webconsole/src/main/angular/src/app/keycerts/est-re-enrollment.interface.ts b/ids-webconsole/src/main/angular/src/app/keycerts/est-re-enrollment.interface.ts new file mode 100644 index 00000000..1f993815 --- /dev/null +++ b/ids-webconsole/src/main/angular/src/app/keycerts/est-re-enrollment.interface.ts @@ -0,0 +1,5 @@ +export interface EstReEnrollment { + estUrl: string; + rootCertHash: string; + alias: string; +}; diff --git a/ids-webconsole/src/main/angular/src/app/keycerts/est-service.ts b/ids-webconsole/src/main/angular/src/app/keycerts/est-service.ts index 24b06b33..75967c41 100644 --- a/ids-webconsole/src/main/angular/src/app/keycerts/est-service.ts +++ b/ids-webconsole/src/main/angular/src/app/keycerts/est-service.ts @@ -4,6 +4,7 @@ import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; import { EstEnrollment } from './est-enrollment.interface'; +import { EstReEnrollment } from './est-re-enrollment.interface'; @Injectable() export class ESTService { @@ -40,4 +41,12 @@ export class ESTService { responseType: 'text' }); } + + // Renew an existing identity identified by its alias via the EST + public renewIdentity(data: EstReEnrollment) { + return this.http.post(environment.apiURL + '/certs/renew_est_identity', data, { + headers: new HttpHeaders({'Content-Type': 'application/json'}), + responseType: 'text' + }); + } } diff --git a/ids-webconsole/src/main/angular/src/app/keycerts/identityrenewest.component.html b/ids-webconsole/src/main/angular/src/app/keycerts/identityrenewest.component.html new file mode 100644 index 00000000..9014bb8d --- /dev/null +++ b/ids-webconsole/src/main/angular/src/app/keycerts/identityrenewest.component.html @@ -0,0 +1,30 @@ +
+
+
+

Renew Identity

+
+
+
+
+
EST Re-Enrollment
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+
+
+ diff --git a/ids-webconsole/src/main/angular/src/app/keycerts/identityrenewest.component.ts b/ids-webconsole/src/main/angular/src/app/keycerts/identityrenewest.component.ts new file mode 100644 index 00000000..36f9d33a --- /dev/null +++ b/ids-webconsole/src/main/angular/src/app/keycerts/identityrenewest.component.ts @@ -0,0 +1,50 @@ +import { Component, ViewChild } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { ESTService } from './est-service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { SnackbarComponent } from './snackbar.component'; + +@Component({ + templateUrl: './identityrenewest.component.html' +}) +export class RenewIdentityESTComponent { + estUrl = 'https://daps-dev.aisec.fraunhofer.de'; + rootCertHash = '7d3f260abb4b0bfa339c159398c0ab480a251faa385639218198adcad9a3c17d'; + + @ViewChild("errorSnackbar") + errorSnackbar: SnackbarComponent; + + constructor(private readonly titleService: Title, + private readonly estService: ESTService, + private readonly router: Router, + private readonly route: ActivatedRoute) { + this.titleService.setTitle('Renew Identity via the EST'); + } + + handleError(err: HttpErrorResponse) { + if (err.status === 0) { + this.errorSnackbar.title = 'Network Error'; + } else { + const errObj = JSON.parse(err.error); + if (errObj.message) { + this.errorSnackbar.title = errObj.message; + } else { + // Errors have no message if it is disabled by the spring application + this.errorSnackbar.title = `Error response from connector: ${err.status}: ${errObj.error}`; + } + } + this.errorSnackbar.visible = true; + } + + onSubmit() { + this.estService.renewIdentity({ + estUrl: this.estUrl, + rootCertHash: this.rootCertHash, + alias: this.route.snapshot.paramMap.get('alias') + }).subscribe( + () => this.router.navigate([ '/certificates' ]), + err => this.handleError(err) + ); + } +} diff --git a/ids-webconsole/src/main/angular/src/app/keycerts/keycerts.component.html b/ids-webconsole/src/main/angular/src/app/keycerts/keycerts.component.html index 90166399..1aeb1b11 100755 --- a/ids-webconsole/src/main/angular/src/app/keycerts/keycerts.component.html +++ b/ids-webconsole/src/main/angular/src/app/keycerts/keycerts.component.html @@ -5,7 +5,7 @@

My Identities

- +
+
diff --git a/ids-webconsole/src/main/angular/src/app/keycerts/snackbar.component.ts b/ids-webconsole/src/main/angular/src/app/keycerts/snackbar.component.ts new file mode 100644 index 00000000..595724fa --- /dev/null +++ b/ids-webconsole/src/main/angular/src/app/keycerts/snackbar.component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'snackbar', + templateUrl: './snackbar.component.html', + styleUrl: './snackbar.component.css' +}) +export class SnackbarComponent { + @Input() title: string = null; + @Input() subtitle: string = null; + @Input() visible: boolean = false; + @Input() onDismiss: ()=>void = null; + + invokeOnDismiss() { + if (this.onDismiss !== null) { + this.onDismiss() + } else { + this.visible = false; + } + } +} \ No newline at end of file diff --git a/ids-webconsole/src/main/kotlin/de/fhg/aisec/ids/webconsole/api/CertApi.kt b/ids-webconsole/src/main/kotlin/de/fhg/aisec/ids/webconsole/api/CertApi.kt index 0f3cb722..1c7d0240 100644 --- a/ids-webconsole/src/main/kotlin/de/fhg/aisec/ids/webconsole/api/CertApi.kt +++ b/ids-webconsole/src/main/kotlin/de/fhg/aisec/ids/webconsole/api/CertApi.kt @@ -26,6 +26,7 @@ import de.fhg.aisec.ids.api.acme.AcmeTermsOfService import de.fhg.aisec.ids.api.settings.Settings import de.fhg.aisec.ids.webconsole.ApiController import de.fhg.aisec.ids.webconsole.api.data.Cert +import de.fhg.aisec.ids.webconsole.api.data.EstIdRenewRequest import de.fhg.aisec.ids.webconsole.api.data.EstIdRequest import de.fhg.aisec.ids.webconsole.api.data.Identity import de.fhg.aisec.ids.webconsole.api.helper.ProcessExecutor @@ -41,6 +42,7 @@ import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess import io.ktor.serialization.jackson.jackson import io.swagger.annotations.Api import io.swagger.annotations.ApiOperation @@ -82,7 +84,9 @@ import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.Base64 import java.util.UUID +import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext +import javax.net.ssl.SSLParameters import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager import javax.security.auth.x500.X500Principal @@ -360,7 +364,7 @@ class CertApi( @PostMapping("/request_est_identity", consumes = [MediaType.APPLICATION_JSON]) @ApiOperation( - value = "Get CA certificate from EST", + value = "Get CA certificate from EST server", notes = "" ) @ApiResponses( @@ -409,6 +413,65 @@ class CertApi( } } + @PostMapping("/renew_est_identity", consumes = [MediaType.APPLICATION_JSON]) + @ApiOperation("Renew a certificate from an EST") + @ApiResponses( + ApiResponse(code = 200, message = "Successfully renewed the certificate"), + ApiResponse(code = 400, message = "Error response from the EST"), + ApiResponse(code = 500, message = "Error renewing certificate") + ) + suspend fun renewEstIdentities( + @RequestBody req: EstIdRenewRequest + ) { + LOG.debug("Start renewing EST certificate.") + + val keyStoreFile = getKeystoreFile(settings.connectorConfig.keystoreName) + val keyStore = + KeyStore + .getInstance("pkcs12") + .also { it.load(keyStoreFile.inputStream(), KEYSTORE_PWD.toCharArray()) } + + val oldKey = keyStore.getKey(req.alias, KEYSTORE_PWD.toCharArray()) as PrivateKey + val oldCert = keyStore.getCertificate(req.alias) as X509Certificate + + LOG.debug("Fetching root certificates from EST server...") + val caCerts = fetchEstCaCerts(req.estUrl, req.rootCertHash) + + caCerts.firstOrNull { it.verify(it) }?.let { + LOG.debug("Storing root CA certificate in TrustStore...") + storeCertificate(settings.connectorConfig.truststoreName, listOf(it)) + } ?: LOG.warn("No (valid) root CA certificate has been found. EST process may fail!") + + LOG.debug("Generating new keys...") + val keys = KeyPairGenerator.getInstance("RSA").apply { initialize(4096) }.generateKeyPair() + + LOG.debug("Generating CSR...") + val csr = generatePKCS10(keys) + + LOG.debug("Sending EST renew request...") + val pkcs7 = sendEstIdRenewReq(req, csr, oldKey, oldCert) + + pkcs7.certificates.firstOrNull { it.publicKey == keys.public }?.let { + LOG.debug("Found EST certificate, assembling certificate chain...") + val certificateChain = mutableListOf(it) + var lastCertificate = it + // The last certificate (root) is self-signed + while (!lastCertificate.verify(lastCertificate)) { + // Find CA certificate signing last element of chain + caCerts.firstOrNull { ca -> lastCertificate.verify(ca) }?.let { nextCa -> + certificateChain += nextCa + lastCertificate = nextCa + } ?: throw RuntimeException( + "Could not create certificate chain, " + + "did not find signer for this certificate:\n$lastCertificate" + ) + } + LOG.debug("Storing EST certificate (full chain) using alias \"{}\"...", req.alias) + storeCertificate(settings.connectorConfig.keystoreName, certificateChain, keys.private, req.alias) + LOG.debug("EST re-enrollment completed successfully!") + } + } + @Throws(java.lang.Exception::class) private fun generatePKCS10(keys: KeyPair): ByteArray { val sigAlg = "SHA256WithRSA" @@ -485,6 +548,79 @@ class CertApi( return PKCS7(encoded) } + private suspend fun sendEstIdRenewReq( + req: EstIdRenewRequest, + csr: ByteArray, + clientKey: PrivateKey, + clientCert: X509Certificate + ): PKCS7 { + val trustStoreFile = getKeystoreFile(settings.connectorConfig.truststoreName) + val trustManagers = + TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()) + .also { tmf -> + KeyStore.getInstance("pkcs12").also { + FileInputStream(trustStoreFile).use { fis -> + it.load(fis, KEYSTORE_PWD.toCharArray()) + tmf.init(it) + } + } + }.trustManagers + val keyStore = KeyStore.getInstance("pkcs12") + // This does not perform IO since this load creates a new keystore instance + @Suppress("BlockingMethodInNonBlockingContext") + keyStore.load(null) + keyStore.setKeyEntry("1", clientKey, "".toCharArray(), arrayOf(clientCert)) + val keyManagers = + KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .also { + it.init(keyStore, null) + }.keyManagers + val secureHttpClient = + HttpClient(Java) { + engine { + config { + sslContext( + SSLContext.getInstance("TLS").apply { + init(keyManagers, trustManagers, null) + } + ) + sslParameters( + SSLParameters().apply { + needClientAuth = true + } + ) + } + } + install(ContentNegotiation) { + jackson() + } + } + + val url = "${req.estUrl}/.well-known/est/simplereenroll" + val csrString = String(csr, StandardCharsets.UTF_8).replace(CLEAR_PEM_REGEX, "") + val resp = + secureHttpClient.post(url) { + setBody(csrString) + headers { + append("Content-Type", "application/pkcs10") + append("Content-Transfer-Encoding", "base64") + } + } + + if (!resp.status.isSuccess()) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Failed to fetch renewed certificate from EST server: ${resp.bodyAsText()}" + ) + } + + val res = resp.bodyAsText() + val encoded = Base64.getDecoder().decode(res.replace(WHITESPACE_REGEX, "")) + return PKCS7(encoded) + } + private fun storeCertificate( storeFilename: String, certificateChain: List, diff --git a/ids-webconsole/src/main/kotlin/de/fhg/aisec/ids/webconsole/api/data/EstIdRenewRequest.kt b/ids-webconsole/src/main/kotlin/de/fhg/aisec/ids/webconsole/api/data/EstIdRenewRequest.kt new file mode 100644 index 00000000..f596c60f --- /dev/null +++ b/ids-webconsole/src/main/kotlin/de/fhg/aisec/ids/webconsole/api/data/EstIdRenewRequest.kt @@ -0,0 +1,26 @@ +/*- + * ========================LICENSE_START================================= + * ids-webconsole + * %% + * Copyright (C) 2024 Fraunhofer AISEC + * %% + * 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 + * + * http://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. + * =========================LICENSE_END================================== + */ +package de.fhg.aisec.ids.webconsole.api.data + +data class EstIdRenewRequest( + val estUrl: String, + val rootCertHash: String, + val alias: String +)