From 42fb785c90c96a1e3e4cd27d82fc2e43aec56ad9 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Wed, 27 May 2026 11:58:29 +0200 Subject: [PATCH 1/2] fix: preserve JWK fields when adding key pair to DID document When the serialized public key is already in JWK format, use it directly rather than round-tripping through a PublicKey, which drops extra JWK fields (alg, x5u, etc.). Falls back to the key parser registry for non-JWK formats such as PEM. Co-Authored-By: Claude Sonnet 4.6 --- .../did/DidDocumentServiceImpl.java | 29 +++-- .../did/DidDocumentServiceImplTest.java | 119 ++++++++++++++++++ 2 files changed, 141 insertions(+), 7 deletions(-) diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java index ee252dcf6..81f1f5b0a 100644 --- a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java @@ -14,6 +14,7 @@ package org.eclipse.edc.identityhub.did; +import com.nimbusds.jose.jwk.JWK; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.instrumentation.annotations.WithSpan; import org.eclipse.edc.iam.did.spi.document.DidDocument; @@ -38,6 +39,7 @@ import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.AbstractResult; +import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.result.ServiceResult; import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.spi.telemetry.Telemetry; @@ -46,11 +48,13 @@ import java.security.KeyPair; import java.security.PublicKey; +import java.text.ParseException; import java.util.Collection; import java.util.function.Supplier; import java.util.stream.Collectors; import static org.eclipse.edc.participantcontext.spi.types.ParticipantResource.queryByParticipantContextId; +import static org.eclipse.edc.spi.result.Result.failure; import static org.eclipse.edc.spi.result.ServiceResult.success; /** @@ -285,20 +289,23 @@ private void keyPairActivated(KeyPairActivated event) { // add the public key as verification method to all did resources var serialized = event.getPublicKeySerialized(); - var publicKey = keyParserRegistry.parse(serialized); - - if (publicKey.failed()) { - monitor.warning("Error adding KeyPair '%s' to DID Document of participant '%s': %s".formatted(event.getKeyPairResource().getId(), event.getParticipantContextId(), publicKey.getFailureDetail())); + + // only convert if not already in JWK format + var jwkResult = tryParseJwk(serialized) + .onFailure(f -> monitor.debug("Serialized key is not JWK (message: %s), attempting to convert".formatted(f.getFailureDetail()))) + .recover(f -> keyParserRegistry.parse(serialized) + .map(pk -> CryptoConverter.createJwk(new KeyPair((PublicKey) pk, null)))); + + if (jwkResult.failed()) { + monitor.warning("Error adding KeyPair '%s' to DID Document of participant '%s': %s".formatted(event.getKeyPairResource().getId(), event.getParticipantContextId(), jwkResult.getFailureDetail())); return; } - var jwk = CryptoConverter.createJwk(new KeyPair((PublicKey) publicKey.getContent(), null)); - var errors = didResources.stream() .map(dd -> { dd.getDocument().getVerificationMethod().add(VerificationMethod.Builder.newInstance() .id(event.getKeyId()) - .publicKeyJwk(jwk.toJSONObject()) + .publicKeyJwk(jwkResult.getContent().toJSONObject()) .controller(dd.getDocument().getId()) .type(event.getKeyType()) .build()); @@ -315,6 +322,14 @@ private void keyPairActivated(KeyPairActivated event) { }); } + private Result tryParseJwk(String serialized) { + try { + return Result.success(JWK.parse(serialized)); + } catch (ParseException e) { + return failure("Failed to parse JWK from string: %s".formatted(e)); + } + } + @WithSpan(value = "did-document.keypair-revoked", kind = SpanKind.INTERNAL) private void keypairRevoked(KeyPairRevoked event) { var didResources = findByParticipantContextId(event.getParticipantContextId()); diff --git a/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java b/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java index bd3a08cd8..1473eaca9 100644 --- a/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java +++ b/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java @@ -16,7 +16,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; import org.eclipse.edc.iam.did.spi.document.DidDocument; import org.eclipse.edc.iam.did.spi.document.Service; @@ -47,6 +49,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.net.URI; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; import java.util.List; import java.util.UUID; @@ -570,6 +578,117 @@ void onKeyPairActivated() throws JOSEException { verify(publisherMock).publish(eq(did)); } + @SuppressWarnings("unchecked") + @Test + void onKeyPairActivated_withRsaKey() throws NoSuchAlgorithmException { + var keyId = "key-id"; + var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + var keyPair = keyPairGenerator.generateKeyPair(); + var key = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) + .privateKey((RSAPrivateKey) keyPair.getPrivate()) + .keyID(keyId) + .algorithm(JWSAlgorithm.RS256) + .x509CertURL(URI.create("https://example.com/cert")) + .build(); + var doc = createDidDocument().build(); + var did = doc.getId(); + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.GENERATED).document(doc).build(); + + when(didResourceStoreMock.query(any(QuerySpec.class))).thenReturn(List.of(didResource)); + when(didResourceStoreMock.update(any())).thenReturn(StoreResult.success()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(didResource); + when(publisherMock.publish(did)).thenReturn(Result.success()); + + var event = EventEnvelope.Builder.newInstance() + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .payload(KeyPairActivated.Builder.newInstance() + .keyId(keyId) + .keyPairResource(KeyPairResource.Builder.newPresentationSigning().id(UUID.randomUUID().toString()).build()) + .participantContextId("test-participant") + .publicKey(key.toPublicJWK().toJSONString(), JSON_WEB_KEY_2020) + .build()) + .build(); + + service.on(event); + + verify(didResourceStoreMock).query(any(QuerySpec.class)); + verify(didResourceStoreMock).update(argThat(dr -> + dr.getDocument().getVerificationMethod().stream().anyMatch(vm -> vm.getId().equals(keyId) && + vm.getPublicKeyJwk().containsKey("x5u") && + vm.getPublicKeyJwk().containsKey("alg")))); + verify(didResourceStoreMock).findById(did); // happens during the publishing + verifyNoMoreInteractions(didResourceStoreMock); + verify(publisherMock).publish(eq(did)); + } + + @SuppressWarnings("unchecked") + @Test + void onKeyPairActivated_withPemKey() throws JOSEException { + var keyId = "key-id"; + var key = new ECKeyGenerator(Curve.P_256).keyID(keyId).generate(); + var encoded = Base64.getMimeEncoder(64, new byte[]{ '\n' }).encodeToString(key.toPublicJWK().toECPublicKey().getEncoded()); + var pem = "-----BEGIN PUBLIC KEY-----\n" + encoded + "\n-----END PUBLIC KEY-----"; + + var doc = createDidDocument().build(); + var did = doc.getId(); + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.GENERATED).document(doc).build(); + + when(didResourceStoreMock.query(any(QuerySpec.class))).thenReturn(List.of(didResource)); + when(didResourceStoreMock.update(any())).thenReturn(StoreResult.success()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(didResource); + when(publisherMock.publish(did)).thenReturn(Result.success()); + + var event = EventEnvelope.Builder.newInstance() + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .payload(KeyPairActivated.Builder.newInstance() + .keyId(keyId) + .keyPairResource(KeyPairResource.Builder.newPresentationSigning().id(UUID.randomUUID().toString()).build()) + .participantContextId("test-participant") + .publicKey(pem, JSON_WEB_KEY_2020) + .build()) + .build(); + + service.on(event); + + verify(didResourceStoreMock).query(any(QuerySpec.class)); + verify(didResourceStoreMock).update(argThat(dr -> dr.getDocument().getVerificationMethod().stream().anyMatch(vm -> vm.getId().equals(keyId)))); + verify(didResourceStoreMock).findById(did); + verifyNoMoreInteractions(didResourceStoreMock); + verify(publisherMock).publish(eq(did)); + } + + @SuppressWarnings("unchecked") + @Test + void onKeyPairActivated_withInvalidKey_shouldLogWarning() { + var keyId = "key-id"; + var doc = createDidDocument().build(); + var did = doc.getId(); + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.GENERATED).document(doc).build(); + + when(didResourceStoreMock.query(any(QuerySpec.class))).thenReturn(List.of(didResource)); + + var event = EventEnvelope.Builder.newInstance() + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .payload(KeyPairActivated.Builder.newInstance() + .keyId(keyId) + .keyPairResource(KeyPairResource.Builder.newPresentationSigning().id(UUID.randomUUID().toString()).build()) + .participantContextId("test-participant") + .publicKey("not-a-valid-key", JSON_WEB_KEY_2020) + .build()) + .build(); + + service.on(event); + + verify(monitorMock).warning(anyString()); + verify(didResourceStoreMock).query(any(QuerySpec.class)); + verifyNoMoreInteractions(didResourceStoreMock); + verifyNoInteractions(publisherMock); + } + @SuppressWarnings("unchecked") @Test void onKeyPairRevoked() throws JOSEException { From b879e8cafe175b0a26f59017b3659bd23ecb0049 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Wed, 27 May 2026 12:04:05 +0200 Subject: [PATCH 2/2] update api version file --- .../src/main/resources/identity-api-version.json | 2 +- .../src/main/resources/issuer-admin-api-version.json | 2 +- .../dcp-issuer-api/src/main/resources/issuer-api-version.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/api/identity-api/identity-api-configuration/src/main/resources/identity-api-version.json b/extensions/api/identity-api/identity-api-configuration/src/main/resources/identity-api-version.json index 20d4370bc..720078a48 100644 --- a/extensions/api/identity-api/identity-api-configuration/src/main/resources/identity-api-version.json +++ b/extensions/api/identity-api/identity-api-configuration/src/main/resources/identity-api-version.json @@ -2,7 +2,7 @@ { "version": "1.0.0-alpha", "urlPath": "/v1beta", - "lastUpdated": "2026-05-18T11:00:00Z", + "lastUpdated": "2026-05-27T11:00:00Z", "maturity": null } ] diff --git a/extensions/api/issuer-admin-api/issuer-admin-api-configuration/src/main/resources/issuer-admin-api-version.json b/extensions/api/issuer-admin-api/issuer-admin-api-configuration/src/main/resources/issuer-admin-api-version.json index f34ba2a81..f81adc4df 100644 --- a/extensions/api/issuer-admin-api/issuer-admin-api-configuration/src/main/resources/issuer-admin-api-version.json +++ b/extensions/api/issuer-admin-api/issuer-admin-api-configuration/src/main/resources/issuer-admin-api-version.json @@ -2,7 +2,7 @@ { "version": "1.0.0-alpha", "urlPath": "/v1beta", - "lastUpdated": "2026-03-10T15:00:00Z", + "lastUpdated": "2026-05-27T15:00:00Z", "maturity": null } ] diff --git a/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/resources/issuer-api-version.json b/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/resources/issuer-api-version.json index 5c4a5ce4e..d9fa2ec3f 100644 --- a/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/resources/issuer-api-version.json +++ b/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/resources/issuer-api-version.json @@ -2,7 +2,7 @@ { "version": "1.0.0", "urlPath": "/v1beta", - "lastUpdated": "2025-11-12T16:00:00Z", + "lastUpdated": "2026-05-27T16:00:00Z", "maturity": null } ]