diff --git a/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImpl.java b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImpl.java index 01c03e3a2..52c17600f 100644 --- a/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImpl.java +++ b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImpl.java @@ -209,7 +209,7 @@ private Result generateOrGetKey(KeyDescriptor keyDescriptor) { if (keyDescriptor.getKeyGeneratorParams() != null) { var keyPair = KeyPairGenerator.generateKeyPair(keyDescriptor.getKeyGeneratorParams()); if (keyPair.failed()) { - return keyPair.mapTo(); + return keyPair.mapFailure(); } var privateJwk = CryptoConverter.createJwk(keyPair.getContent(), keyDescriptor.getKeyId()); publicKeySerialized = privateJwk.toPublicJWK().toJSONString(); diff --git a/core/lib/accesstoken-lib/src/main/java/org/eclipse/edc/identityhub/accesstoken/verification/AccessTokenVerifierImpl.java b/core/lib/accesstoken-lib/src/main/java/org/eclipse/edc/identityhub/accesstoken/verification/AccessTokenVerifierImpl.java index 0a7f666fe..e7cc22d3d 100644 --- a/core/lib/accesstoken-lib/src/main/java/org/eclipse/edc/identityhub/accesstoken/verification/AccessTokenVerifierImpl.java +++ b/core/lib/accesstoken-lib/src/main/java/org/eclipse/edc/identityhub/accesstoken/verification/AccessTokenVerifierImpl.java @@ -63,7 +63,7 @@ public Result> verify(String token, String participantId) { Objects.requireNonNull(participantId, "Participant ID is mandatory."); var res = tokenValidationService.validate(token, publicKeyResolver, tokenValidationRulesRegistry.getRules(IATP_SELF_ISSUED_TOKEN_CONTEXT)); if (res.failed()) { - return res.mapTo(); + return res.mapFailure(); } var claimToken = res.getContent(); @@ -94,7 +94,7 @@ public Result> verify(String token, String participantId) { rules.add(audMustMatchParticipantIdRule); var result = tokenValidationService.validate(accessTokenString, id -> Result.success(stsPublicKey.get()), rules); if (result.failed()) { - return result.mapTo(); + return result.mapFailure(); } // verify that the access_token contains a scope claim diff --git a/core/lib/keypair-lib/build.gradle.kts b/core/lib/keypair-lib/build.gradle.kts index 018014ffe..69447eb3a 100644 --- a/core/lib/keypair-lib/build.gradle.kts +++ b/core/lib/keypair-lib/build.gradle.kts @@ -3,7 +3,10 @@ plugins { } dependencies { + implementation(project(":spi:identity-hub-store-spi")) implementation(libs.edc.spi.core) implementation(libs.edc.lib.util) + implementation(libs.edc.lib.keys) testImplementation(libs.edc.junit) + testImplementation(libs.nimbus.jwt) } diff --git a/core/lib/keypair-lib/src/main/java/org/eclipse/edc/identityhub/publickey/KeyPairResourcePublicKeyResolver.java b/core/lib/keypair-lib/src/main/java/org/eclipse/edc/identityhub/publickey/KeyPairResourcePublicKeyResolver.java new file mode 100644 index 000000000..6f4baeedf --- /dev/null +++ b/core/lib/keypair-lib/src/main/java/org/eclipse/edc/identityhub/publickey/KeyPairResourcePublicKeyResolver.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.publickey; + +import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore; +import org.eclipse.edc.keys.LocalPublicKeyServiceImpl; +import org.eclipse.edc.keys.spi.KeyParserRegistry; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.Vault; + +import java.security.PublicKey; + +/** + * This {@link org.eclipse.edc.keys.spi.LocalPublicKeyService} resolves this IdentityHub's own public keys by querying the {@link KeyPairResourceStore}. + * The rationale being that public keys should be represented as a {@link org.eclipse.edc.identityhub.spi.keypair.model.KeyPairResource}. + *

+ * If no such {@link org.eclipse.edc.identityhub.spi.keypair.model.KeyPairResource} is found, this service will fall back to looking up the key in the vault. Note that this + * would be a strong indication of a data inconsistency. + */ +public class KeyPairResourcePublicKeyResolver extends LocalPublicKeyServiceImpl { + + private final KeyPairResourceStore keyPairResourceStore; + private final KeyParserRegistry keyParserRegistry; + private final Monitor monitor; + + public KeyPairResourcePublicKeyResolver(Vault vault, KeyPairResourceStore keyPairResourceStore, KeyParserRegistry registry, Monitor monitor) { + super(vault, registry); + this.keyPairResourceStore = keyPairResourceStore; + this.keyParserRegistry = registry; + this.monitor = monitor; + } + + @Override + public Result resolveKey(String id) { + return resolveFromDbOrVault(id); + } + + private Result resolveFromDbOrVault(String publicKeyId) { + var result = keyPairResourceStore.query(QuerySpec.Builder.newInstance().filter(new Criterion("keyId", "=", publicKeyId)).build()); + // store failed, e.g. data model does not match query, etc. + if (result.failed()) { + monitor.warning("Error querying database for KeyPairResource with key ID '%s': %s".formatted(publicKeyId, result.getFailureDetail())); + return Result.failure(result.getFailureDetail()); + } + + var resources = result.getContent(); + if (resources.size() > 1) { + monitor.warning("Expected exactly 1 KeyPairResource with keyId '%s' but found '%d'. This indicates a database inconsistency. Will return the first one.".formatted(publicKeyId, resources.size())); + } + return resources.stream().findAny() + .map(kpr -> parseKey(kpr.getSerializedPublicKey())) + .orElseGet(() -> { + monitor.warning("No KeyPairResource with keyId '%s' was found in the store. Will attempt to resolve from the Vault. This could be an indication of a data inconsistency, it is recommended to revoke and regenerate keys!"); + return super.resolveKey(publicKeyId); // attempt to resolve from vault + }); + } + + // super-class's method is private, simply temporarily copy-pasted here + private Result parseKey(String encodedKey) { + return keyParserRegistry.parse(encodedKey).compose(pk -> { + if (pk instanceof PublicKey publicKey) { + return Result.success(publicKey); + } else { + return Result.failure("The specified resource did not contain public key material."); + } + }); + } +} diff --git a/core/lib/keypair-lib/src/test/java/org/eclipse/edc/identityhub/publickey/KeyPairResourcePublicKeyResolverTest.java b/core/lib/keypair-lib/src/test/java/org/eclipse/edc/identityhub/publickey/KeyPairResourcePublicKeyResolverTest.java new file mode 100644 index 000000000..04688f7f1 --- /dev/null +++ b/core/lib/keypair-lib/src/test/java/org/eclipse/edc/identityhub/publickey/KeyPairResourcePublicKeyResolverTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.publickey; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator; +import org.eclipse.edc.identityhub.spi.keypair.model.KeyPairResource; +import org.eclipse.edc.identityhub.spi.keypair.model.KeyPairState; +import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore; +import org.eclipse.edc.keys.KeyParserRegistryImpl; +import org.eclipse.edc.keys.keyparsers.JwkParser; +import org.eclipse.edc.keys.spi.KeyParserRegistry; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.StoreResult; +import org.eclipse.edc.spi.security.Vault; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class KeyPairResourcePublicKeyResolverTest { + + private final Vault vault = mock(); + private final KeyPairResourceStore resourceStore = mock(); + private final KeyParserRegistry parserRegistry = new KeyParserRegistryImpl(); + private final Monitor monitor = mock(); + private final KeyPairResourcePublicKeyResolver resolver = new KeyPairResourcePublicKeyResolver(vault, resourceStore, parserRegistry, monitor); + + @BeforeEach + void setUp() { + parserRegistry.register(new JwkParser(new ObjectMapper(), monitor)); + } + + @Test + void resolveKey_whenFound() { + when(resourceStore.query(any(QuerySpec.class))).thenReturn(StoreResult.success(Collections.singletonList(createKeyPairResource().build()))); + + assertThat(resolver.resolveKey("test-key")).isSucceeded(); + verify(resourceStore).query(any(QuerySpec.class)); + verifyNoMoreInteractions(resourceStore); + verifyNoInteractions(vault, monitor); + } + + @Test + void resolveKey_whenNotFoundInStore_foundInVault() { + when(resourceStore.query(any(QuerySpec.class))).thenReturn(StoreResult.success(Collections.emptyList())); + when(vault.resolveSecret(anyString())).thenReturn(createPublicKeyJwk()); + + assertThat(resolver.resolveKey("test-key")).isSucceeded(); + + verify(resourceStore).query(any(QuerySpec.class)); + verify(vault).resolveSecret(anyString()); + verify(monitor).warning(contains("Will attempt to resolve from the Vault.")); + verifyNoMoreInteractions(vault, resourceStore); + } + + @Test + void resolveKey_whenStoreFailure() { + when(resourceStore.query(any(QuerySpec.class))).thenReturn(StoreResult.notFound("foo-bar")); + + assertThat(resolver.resolveKey("test-key")).isFailed().detail().isEqualTo("foo-bar"); + + verify(resourceStore).query(any(QuerySpec.class)); + verify(monitor).warning(contains("Error querying database for KeyPairResource")); + verifyNoMoreInteractions(vault, resourceStore); + } + + @Test + void resolveKey_whenNotFoundInResourceStore_notFoundInVault() { + when(resourceStore.query(any(QuerySpec.class))).thenReturn(StoreResult.success(Collections.emptyList())); + when(vault.resolveSecret(anyString())).thenReturn(null); + + assertThat(resolver.resolveKey("test-key")).isFailed() + .detail() + .contains("No public key could be resolved for key-ID 'test-key'"); + + verify(resourceStore).query(any(QuerySpec.class)); + verify(vault).resolveSecret(anyString()); + verify(monitor).warning(contains("Will attempt to resolve from the Vault.")); + verifyNoMoreInteractions(vault, resourceStore); + } + + @Test + void resolveKey_whenMultipleFoundInStore() { + when(resourceStore.query(any(QuerySpec.class))).thenReturn(StoreResult.success(List.of( + createKeyPairResource().build(), + createKeyPairResource().build() + ))); + + assertThat(resolver.resolveKey("test-key")).isSucceeded(); + verify(resourceStore).query(any(QuerySpec.class)); + verify(monitor).warning(matches("Expected exactly 1 KeyPairResource with keyId '.*' but found '2'.")); + verifyNoMoreInteractions(resourceStore, monitor); + } + + @Test + void resolveKey_whenNotPublicKey() throws JOSEException { + when(resourceStore.query(any(QuerySpec.class))).thenReturn(StoreResult.success(Collections.singletonList(createKeyPairResource() + .serializedPublicKey(new OctetKeyPairGenerator(Curve.Ed25519).generate().toJSONString()).build()))); + + assertThat(resolver.resolveKey("test-key")).isFailed() + .detail().contains("The specified resource did not contain public key material."); + verify(resourceStore).query(any(QuerySpec.class)); + verifyNoMoreInteractions(resourceStore); + verifyNoInteractions(vault, monitor); + } + + @Test + void resolveKey_whenInvalidFormat() { + when(resourceStore.query(any(QuerySpec.class))).thenReturn(StoreResult.success(Collections.singletonList(createKeyPairResource() + .serializedPublicKey("this-is-not-jwk-or-pem").build()))); + + assertThat(resolver.resolveKey("test-key")).isFailed() + .detail().contains("No parser found that can handle that format."); + verify(resourceStore).query(any(QuerySpec.class)); + verifyNoMoreInteractions(resourceStore); + verifyNoInteractions(vault, monitor); + } + + private KeyPairResource.Builder createKeyPairResource() { + return KeyPairResource.Builder.newInstance() + .id(UUID.randomUUID().toString()) + .keyId(UUID.randomUUID().toString()) + .isDefaultPair(true) + .state(KeyPairState.ACTIVE) + .serializedPublicKey(createPublicKeyJwk()) + .privateKeyAlias("test-key-alias"); + } + + private String createPublicKeyJwk() { + try { + return new OctetKeyPairGenerator(Curve.Ed25519).generate().toPublicJWK().toJSONString(); + } catch (JOSEException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file