diff --git a/README.md b/README.md index f026731b9..6bb52a4ae 100644 --- a/README.md +++ b/README.md @@ -636,6 +636,24 @@ client.subscribeToTask(taskIdParams, clientCallContext); AgentCard serverAgentCard = client.getAgentCard(); ``` +## Recent Parity Updates + +These features align the Java SDK with recent protocol and server capabilities: + +### 1. ID Generation SPI +The SDK uses an SPI for generating unique identifiers for tasks, messages, and events. + +- **Interface**: `org.a2aproject.sdk.server.util.IdGenerator` +- **Default implementation**: `org.a2aproject.sdk.server.util.UUIDIdGenerator` + +### 2. AgentCard signing and security +Supports signing agent cards using JSON Web Signatures (JWS) for authenticity. + +- **`SigningService`**: Uses `nimbus-jose-jwt` for cryptographic operations (`io.a2a.server.security.SigningService`). + +### 3. Telemetry and observability +Distributed tracing via OpenTelemetry is integrated into core server components via `io.a2a.server.telemetry.A2ATelemetry`. + ## Additional Examples ### Hello World Client Example diff --git a/server-common/pom.xml b/server-common/pom.xml index 21f078782..efc9c13e0 100644 --- a/server-common/pom.xml +++ b/server-common/pom.xml @@ -47,6 +47,14 @@ io.smallrye.reactive mutiny-zero + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-annotations + jakarta.enterprise jakarta.enterprise.cdi-api @@ -59,6 +67,11 @@ org.slf4j slf4j-api + + com.nimbusds + nimbus-jose-jwt + 9.37.3 + io.quarkus quarkus-arc diff --git a/server-common/src/main/java/io/a2a/server/security/.gitkeep b/server-common/src/main/java/io/a2a/server/security/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/server-common/src/main/java/io/a2a/server/security/SecurityUtils.java b/server-common/src/main/java/io/a2a/server/security/SecurityUtils.java new file mode 100644 index 000000000..6ab53135d --- /dev/null +++ b/server-common/src/main/java/io/a2a/server/security/SecurityUtils.java @@ -0,0 +1,58 @@ +package io.a2a.server.security; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.OctetKeyPair; +import com.nimbusds.jose.jwk.RSAKey; + +import java.security.PrivateKey; +import java.security.PublicKey; + +/** + * Utility class for security-related operations, including key conversion. + * + * @author Sandeep Belgavi + */ +public class SecurityUtils { + + private SecurityUtils() { + // Private constructor to prevent instantiation + } + + /** + * Converts a JWK to a Java PrivateKey. + * + * @param jwk the JSON Web Key + * @return the PrivateKey + * @throws JOSEException if conversion fails or key type is unsupported + */ + public static PrivateKey toPrivateKey(JWK jwk) throws JOSEException { + if (jwk instanceof RSAKey rsaKey) { + return rsaKey.toPrivateKey(); + } else if (jwk instanceof ECKey ecKey) { + return ecKey.toPrivateKey(); + } else if (jwk instanceof OctetKeyPair okpKey) { + return okpKey.toPrivateKey(); + } + throw new JOSEException("Unsupported JWK type: " + jwk.getKeyType()); + } + + /** + * Converts a JWK to a Java PublicKey. + * + * @param jwk the JSON Web Key + * @return the PublicKey + * @throws JOSEException if conversion fails or key type is unsupported + */ + public static PublicKey toPublicKey(JWK jwk) throws JOSEException { + if (jwk instanceof RSAKey rsaKey) { + return rsaKey.toPublicKey(); + } else if (jwk instanceof ECKey ecKey) { + return ecKey.toPublicKey(); + } else if (jwk instanceof OctetKeyPair okpKey) { + return okpKey.toPublicKey(); + } + throw new JOSEException("Unsupported JWK type: " + jwk.getKeyType()); + } +} diff --git a/server-common/src/main/java/io/a2a/server/security/SigningService.java b/server-common/src/main/java/io/a2a/server/security/SigningService.java new file mode 100644 index 000000000..f0b1932f5 --- /dev/null +++ b/server-common/src/main/java/io/a2a/server/security/SigningService.java @@ -0,0 +1,139 @@ +package io.a2a.server.security; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.factories.DefaultJWSSignerFactory; +import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.util.Base64URL; +import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.spec.A2AServerException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentCardSignature; +import jakarta.enterprise.context.ApplicationScoped; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.List; + +/** + * Service for signing and verifying AgentCards. + * + * @author Sandeep Belgavi + */ +@ApplicationScoped +public class SigningService { + + private final DefaultJWSSignerFactory signerFactory = new DefaultJWSSignerFactory(); + private final DefaultJWSVerifierFactory verifierFactory = new DefaultJWSVerifierFactory(); + + /** + * Signs an AgentCard using the provided private key. + * + * @param card the AgentCard to sign + * @param privateKey the private key (JWK) to use for signing + * @return a new AgentCard instance with the signature added + * @throws JOSEException if signing fails + */ + public AgentCard sign(AgentCard card, JWK privateKey) throws JOSEException { + // 1. Create a version of the card without signatures for signing + AgentCard cardToSign = AgentCard.builder(card).signatures(null).build(); + String jsonPayload; + try { + jsonPayload = JsonUtil.toJson(cardToSign); + } catch (JsonProcessingException e) { + throw new A2AServerException("Failed to serialize AgentCard to JSON", e); + } + + // 2. Determine the algorithm + JWSAlgorithm alg = privateKey.getAlgorithm() != null ? + JWSAlgorithm.parse(privateKey.getAlgorithm().getName()) : null; + + if (alg == null) { + // Default algorithms based on key type if not specified + if (com.nimbusds.jose.jwk.KeyType.RSA.equals(privateKey.getKeyType())) { + alg = JWSAlgorithm.RS256; + } else if (com.nimbusds.jose.jwk.KeyType.EC.equals(privateKey.getKeyType())) { + alg = JWSAlgorithm.ES256; + } else if (com.nimbusds.jose.jwk.KeyType.OKP.equals(privateKey.getKeyType())) { + alg = JWSAlgorithm.EdDSA; + } else { + throw new JOSEException("Unsupported key type for signing: " + privateKey.getKeyType()); + } + } + + // 3. Create the signer and header + JWSSigner signer = signerFactory.createJWSSigner(privateKey, alg); + JWSHeader header = new JWSHeader.Builder(alg) + .keyID(privateKey.getKeyID()) + .build(); + + // 4. Create and sign the JWS object + JWSObject jwsObject = new JWSObject(header, new Payload(jsonPayload)); + jwsObject.sign(signer); + + // 5. Create the AgentCardSignature + AgentCardSignature signature = AgentCardSignature.builder() + .protectedHeader(jwsObject.getHeader().toBase64URL().toString()) + .signature(jwsObject.getSignature().toString()) + .build(); + + // 6. Return a new AgentCard with the added signature + List signatures = new ArrayList<>(); + if (card.signatures() != null) { + signatures.addAll(card.signatures()); + } + signatures.add(signature); + + return AgentCard.builder(card).signatures(signatures).build(); + } + + /** + * Verifies the signatures on an AgentCard using the provided public key. + * + * @param card the AgentCard to verify + * @param publicKey the public key (JWK) to use for verification + * @return true if at least one signature is valid, false otherwise + * @throws JOSEException if verification process fails + * @throws ParseException if signature header parsing fails + */ + public boolean verify(AgentCard card, JWK publicKey) throws JOSEException, ParseException { + if (card.signatures() == null || card.signatures().isEmpty()) { + return false; + } + + // 1. Reconstruct the payload (card without signatures) + AgentCard cardWithoutSignatures = AgentCard.builder(card).signatures(null).build(); + String jsonPayload; + try { + jsonPayload = JsonUtil.toJson(cardWithoutSignatures); + } catch (JsonProcessingException e) { + throw new A2AServerException("Failed to serialize AgentCard to JSON", e); + } + Payload payload = new Payload(jsonPayload); + + // 2. Verify each signature + for (AgentCardSignature sig : card.signatures()) { + Base64URL protectedHeader = new Base64URL(sig.protectedHeader()); + Base64URL signature = new Base64URL(sig.signature()); + + // Reconstruct JWSObject + JWSObject jwsObject = new JWSObject(protectedHeader, payload, signature); + + // Create verifier + JWSVerifier verifier = verifierFactory.createJWSVerifier(jwsObject.getHeader(), SecurityUtils.toPublicKey(publicKey)); + + if (jwsObject.verify(verifier)) { + return true; + } + } + + return false; + } +} diff --git a/server-common/src/main/java/io/a2a/server/telemetry/A2ATelemetry.java b/server-common/src/main/java/io/a2a/server/telemetry/A2ATelemetry.java new file mode 100644 index 000000000..ef36a7fec --- /dev/null +++ b/server-common/src/main/java/io/a2a/server/telemetry/A2ATelemetry.java @@ -0,0 +1,40 @@ +package io.a2a.server.telemetry; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; + +import java.util.function.Supplier; + +/** + * Lightweight tracing helpers using the global OpenTelemetry instance. + */ +public class A2ATelemetry { + + private final Tracer tracer = GlobalOpenTelemetry.get().getTracer("io.a2a.server"); + + public T withSpan(String name, Supplier operation) { + Span span = tracer.spanBuilder(name).startSpan(); + try (Scope scope = span.makeCurrent()) { + return operation.get(); + } catch (Exception e) { + span.recordException(e); + throw e; + } finally { + span.end(); + } + } + + public void withSpan(String name, Runnable operation) { + Span span = tracer.spanBuilder(name).startSpan(); + try (Scope scope = span.makeCurrent()) { + operation.run(); + } catch (Exception e) { + span.recordException(e); + throw e; + } finally { + span.end(); + } + } +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/AgentExecutor.java b/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/AgentExecutor.java index aba33913a..4f5b8fcdb 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/AgentExecutor.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/AgentExecutor.java @@ -4,6 +4,8 @@ import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.spec.A2AError; +import io.opentelemetry.instrumentation.annotations.WithSpan; + /** * Core business logic interface for implementing A2A agent functionality. *

@@ -116,6 +118,7 @@ public interface AgentExecutor { * @param emitter the agent emitter for sending messages, updating status, and streaming artifacts * @throws A2AError if execution fails catastrophically (exception propagates to client) */ + @WithSpan("AgentExecutor.execute") void execute(RequestContext context, AgentEmitter emitter) throws A2AError; /** @@ -147,5 +150,6 @@ public interface AgentExecutor { * @throws org.a2aproject.sdk.spec.TaskNotCancelableError if this agent does not support cancellation * @throws A2AError if cancellation is supported but failed to execute */ + @WithSpan("AgentExecutor.cancel") void cancel(RequestContext context, AgentEmitter emitter) throws A2AError; } diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/RequestContext.java b/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/RequestContext.java index ee8253bac..867548515 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/RequestContext.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/RequestContext.java @@ -4,10 +4,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.UUID; +import java.util.Objects; import java.util.stream.Collectors; import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.util.IdGenerator; +import org.a2aproject.sdk.server.util.UUIDIdGenerator; import org.a2aproject.sdk.spec.InvalidParamsError; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.MessageSendConfiguration; @@ -313,7 +315,7 @@ private List getTextParts(List> parts) { * The builder handles ID generation and validation automatically: *

*
    - *
  • TaskId and ContextId are auto-generated (UUID) if not provided
  • + *
  • TaskId and ContextId are auto-generated if not provided (via {@link IdGenerator})
  • *
  • IDs are validated against message parameters if both are present
  • *
  • Message parameters are updated with generated IDs
  • *
  • Related tasks list is initialized to empty list if null
  • @@ -326,6 +328,7 @@ public static class Builder { private @Nullable Task task; private @Nullable List relatedTasks; private @Nullable ServerCallContext serverCallContext; + private IdGenerator idGenerator = new UUIDIdGenerator(); public Builder setParams(@Nullable MessageSendParams params) { this.params = params; @@ -357,6 +360,11 @@ public Builder setServerCallContext(@Nullable ServerCallContext serverCallContex return this; } + public Builder setIdGenerator(IdGenerator idGenerator) { + this.idGenerator = Objects.requireNonNull(idGenerator); + return this; + } + public @Nullable MessageSendParams getParams() { return params; } @@ -406,11 +414,11 @@ public RequestContext build() throws InvalidParamsError { // 4. Determine final IDs using coalesce pattern: builder → message → generate String finalTaskId = taskId != null ? taskId : messageTaskId != null ? messageTaskId : - UUID.randomUUID().toString(); + idGenerator.generateId(); String finalContextId = contextId != null ? contextId : messageContextId != null ? messageContextId : - UUID.randomUUID().toString(); + idGenerator.generateId(); // 5. Update params if message needs to be updated with final IDs MessageSendParams finalParams = params; diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/SimpleRequestContextBuilder.java b/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/SimpleRequestContextBuilder.java index a340ee451..9d25cb82c 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/SimpleRequestContextBuilder.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/SimpleRequestContextBuilder.java @@ -4,6 +4,8 @@ import java.util.List; import org.a2aproject.sdk.server.tasks.TaskStore; +import org.a2aproject.sdk.server.util.IdGenerator; +import org.a2aproject.sdk.server.util.UUIDIdGenerator; import org.a2aproject.sdk.spec.Task; public class SimpleRequestContextBuilder extends RequestContext.Builder { @@ -11,8 +13,14 @@ public class SimpleRequestContextBuilder extends RequestContext.Builder { private final boolean shouldPopulateReferredTasks; public SimpleRequestContextBuilder(TaskStore taskStore, boolean shouldPopulateReferredTasks) { + this(taskStore, shouldPopulateReferredTasks, new UUIDIdGenerator()); + } + + public SimpleRequestContextBuilder(TaskStore taskStore, boolean shouldPopulateReferredTasks, + IdGenerator idGenerator) { this.taskStore = taskStore; this.shouldPopulateReferredTasks = shouldPopulateReferredTasks; + setIdGenerator(idGenerator); } @Override diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java index 8184d7baf..8f01d914f 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java @@ -43,6 +43,8 @@ import org.a2aproject.sdk.server.tasks.ResultAggregator; import org.a2aproject.sdk.server.tasks.TaskManager; import org.a2aproject.sdk.server.tasks.TaskStore; +import org.a2aproject.sdk.server.util.IdGenerator; +import org.a2aproject.sdk.server.util.UUIDIdGenerator; import org.a2aproject.sdk.server.util.async.EventConsumerExecutorProducer.EventConsumerExecutor; import org.a2aproject.sdk.server.util.async.Internal; import org.a2aproject.sdk.spec.A2AError; @@ -255,7 +257,8 @@ public DefaultRequestHandler(AgentExecutor agentExecutor, TaskStore taskStore, QueueManager queueManager, PushNotificationConfigStore pushConfigStore, MainEventBusProcessor mainEventBusProcessor, @Internal Executor executor, - @EventConsumerExecutor Executor eventConsumerExecutor) { + @EventConsumerExecutor Executor eventConsumerExecutor, + IdGenerator idGenerator) { this.agentExecutor = agentExecutor; this.taskStore = taskStore; this.queueManager = queueManager; @@ -267,7 +270,7 @@ public DefaultRequestHandler(AgentExecutor agentExecutor, TaskStore taskStore, // implementation if the parameter is null. Skip that for now, since otherwise I get CDI errors, and // I am unsure about the correct scope. // Also reworked to make a Supplier since otherwise the builder gets polluted with wrong tasks - this.requestContextBuilder = () -> new SimpleRequestContextBuilder(taskStore, false); + this.requestContextBuilder = () -> new SimpleRequestContextBuilder(taskStore, false, idGenerator); } @PostConstruct @@ -288,7 +291,7 @@ public static DefaultRequestHandler create(AgentExecutor agentExecutor, TaskStor Executor executor, Executor eventConsumerExecutor) { DefaultRequestHandler handler = new DefaultRequestHandler(agentExecutor, taskStore, queueManager, pushConfigStore, - mainEventBusProcessor, executor, eventConsumerExecutor); + mainEventBusProcessor, executor, eventConsumerExecutor, new UUIDIdGenerator()); handler.agentCompletionTimeoutSeconds = 5; handler.consumptionCompletionTimeoutSeconds = 2; diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/util/IdGenerator.java b/server-common/src/main/java/org/a2aproject/sdk/server/util/IdGenerator.java new file mode 100644 index 000000000..d5d3ab90e --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/util/IdGenerator.java @@ -0,0 +1,10 @@ +package org.a2aproject.sdk.server.util; + +/** + * SPI for generating unique identifiers (tasks, messages, events). + */ +@FunctionalInterface +public interface IdGenerator { + + String generateId(); +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/util/UUIDIdGenerator.java b/server-common/src/main/java/org/a2aproject/sdk/server/util/UUIDIdGenerator.java new file mode 100644 index 000000000..94f820be0 --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/util/UUIDIdGenerator.java @@ -0,0 +1,19 @@ +package org.a2aproject.sdk.server.util; + +import java.util.UUID; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Default; + +/** + * Default {@link IdGenerator} using random UUID strings. + */ +@ApplicationScoped +@Default +public class UUIDIdGenerator implements IdGenerator { + + @Override + public String generateId() { + return UUID.randomUUID().toString(); + } +} diff --git a/server-common/src/test/java/io/a2a/server/security/SigningServiceTest.java b/server-common/src/test/java/io/a2a/server/security/SigningServiceTest.java new file mode 100644 index 000000000..43de99f62 --- /dev/null +++ b/server-common/src/test/java/io/a2a/server/security/SigningServiceTest.java @@ -0,0 +1,115 @@ +package io.a2a.server.security; + +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for SigningService. + */ +class SigningServiceTest { + + private SigningService signingService; + + @BeforeEach + void setUp() { + signingService = new SigningService(); + } + + private AgentCard.Builder createTestAgentCardBuilder() { + return AgentCard.builder() + .name("Test Agent") + .description("Test Description") + .supportedInterfaces(Collections.singletonList( + new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(Collections.singletonList("text")) + .defaultOutputModes(Collections.singletonList("text")) + .skills(Collections.emptyList()); + } + + @Test + void testSignAndVerifyRSA() throws Exception { + RSAKey rsaJWK = new RSAKeyGenerator(2048) + .keyID(UUID.randomUUID().toString()) + .keyUse(KeyUse.SIGNATURE) + .generate(); + + RSAKey rsaPublicJWK = rsaJWK.toPublicJWK(); + + AgentCard card = createTestAgentCardBuilder().build(); + + AgentCard signedCard = signingService.sign(card, rsaJWK); + + assertNotNull(signedCard.signatures()); + assertEquals(1, signedCard.signatures().size()); + + boolean verified = signingService.verify(signedCard, rsaPublicJWK); + assertTrue(verified, "Signature should be verified"); + } + + @Test + void testSignAndVerifyEC() throws Exception { + ECKey ecJWK = new ECKeyGenerator(Curve.P_256) + .keyID(UUID.randomUUID().toString()) + .generate(); + + ECKey ecPublicJWK = ecJWK.toPublicJWK(); + + AgentCard card = createTestAgentCardBuilder().build(); + + AgentCard signedCard = signingService.sign(card, ecJWK); + + assertNotNull(signedCard.signatures()); + assertEquals(1, signedCard.signatures().size()); + + boolean verified = signingService.verify(signedCard, ecPublicJWK); + assertTrue(verified, "Signature should be verified"); + } + + @Test + void testVerifyFailsWithModifiedCard() throws Exception { + RSAKey rsaJWK = new RSAKeyGenerator(2048) + .keyID(UUID.randomUUID().toString()) + .generate(); + RSAKey rsaPublicJWK = rsaJWK.toPublicJWK(); + + AgentCard card = createTestAgentCardBuilder().build(); + AgentCard signedCard = signingService.sign(card, rsaJWK); + + // Modify the card content (e.g., description) + AgentCard modifiedCard = AgentCard.builder(signedCard) + .description("Modified Description") + .build(); + + boolean verified = signingService.verify(modifiedCard, rsaPublicJWK); + assertFalse(verified, "Verification should fail for modified card"); + } + + @Test + void testVerifyFailsWithWrongKey() throws Exception { + RSAKey rsaJWK1 = new RSAKeyGenerator(2048).generate(); + RSAKey rsaJWK2 = new RSAKeyGenerator(2048).generate(); + + AgentCard card = createTestAgentCardBuilder().build(); + AgentCard signedCard = signingService.sign(card, rsaJWK1); + + boolean verified = signingService.verify(signedCard, rsaJWK2.toPublicJWK()); + assertFalse(verified, "Verification should fail with wrong key"); + } +}