diff --git a/backend-services/voting-app/build.gradle.kts b/backend-services/voting-app/build.gradle.kts index 1843b3dc6..903840177 100644 --- a/backend-services/voting-app/build.gradle.kts +++ b/backend-services/voting-app/build.gradle.kts @@ -28,6 +28,7 @@ configurations { } repositories { + mavenLocal() mavenCentral() maven { url = uri("https://repo.spring.io/milestone") } } @@ -78,7 +79,7 @@ dependencies { runtimeOnly("org.postgresql:postgresql") implementation("org.cardanofoundation:merkle-tree-java:0.0.7") - implementation("org.cardanofoundation:cip30-data-signature-parser:0.0.11") + implementation("org.cardanofoundation:cip30-data-signature-parser:0.0.12-SNAPSHOT") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/entity/Vote.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/entity/Vote.java index 8e9d6938b..aaee8f24c 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/entity/Vote.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/entity/Vote.java @@ -60,7 +60,7 @@ public class Vote extends AbstractTimestampEntity { private String signature; @Column(name = "payload", nullable = false, columnDefinition = "text", length = 2048) - @Nullable + @Nullable // TODO remove nullable since payload is now always required private String payload; @Column(name = "public_key") diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/DefaultLoginService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/DefaultLoginService.java index 830a9a264..e8ce3711f 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/DefaultLoginService.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/DefaultLoginService.java @@ -81,7 +81,7 @@ public Either login(Web3AuthenticationToken web3Authentica } private Either unwrapLoginVoteEnvelope(Web3ConcreteDetails concreteDetails) { - val jsonBody = concreteDetails.getSignedJson(); + val jsonBody = concreteDetails.getPayload(); val jsonPayloadE = jsonService.decodeCIP93LoginEnvelope(jsonBody); if (jsonPayloadE.isLeft()) { diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3Details.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3Details.java index 46f37eb0a..dd3913f51 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3Details.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3Details.java @@ -21,6 +21,7 @@ public class CardanoWeb3Details implements Web3ConcreteDetails { private Cip30VerificationResult cip30VerificationResult; private CIP93Envelope> envelope; private SignedCIP30 signedCIP30; + private String payload; public String getUri() { return envelope.getUri(); @@ -40,8 +41,12 @@ public String getSignature() { return signedCIP30.getSignature(); } - public Optional getPayload() { - return Optional.empty(); + public String getPayload() { + if (cip30VerificationResult.isHashed()) { + return payload; + } + + return cip30VerificationResult.getMessage(MessageFormat.TEXT); } public Optional getPublicKey() { diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3Filter.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3Filter.java index 0cddb4908..94dc5fb58 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3Filter.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3Filter.java @@ -30,10 +30,12 @@ import java.util.List; import java.util.Optional; +import static com.bloxbean.cardano.client.crypto.Blake2bUtil.blake2bHash224; +import static com.bloxbean.cardano.client.util.HexUtil.decodeHexString; +import static com.bloxbean.cardano.client.util.HexUtil.encodeHexString; import static org.cardano.foundation.voting.domain.Role.VOTER; import static org.cardano.foundation.voting.domain.web3.WalletType.CARDANO; -import static org.cardano.foundation.voting.resource.Headers.X_Ballot_PublicKey; -import static org.cardano.foundation.voting.resource.Headers.X_Ballot_Signature; +import static org.cardano.foundation.voting.resource.Headers.*; import static org.cardano.foundation.voting.service.auth.LoginSystem.CARDANO_CIP93; import static org.cardano.foundation.voting.service.auth.web3.MoreFilters.sendBackProblem; import static org.cardano.foundation.voting.utils.MoreNumber.isNumeric; @@ -77,6 +79,7 @@ protected void doFilterInternal(HttpServletRequest req, val signatureM = Optional.ofNullable(req.getHeader(X_Ballot_Signature)); val publicKey = req.getHeader(X_Ballot_PublicKey); + val payloadM = Optional.ofNullable(req.getHeader(X_Ballot_Payload)); if (signatureM.isEmpty()) { val problem = Problem.builder() @@ -122,7 +125,37 @@ protected void doFilterInternal(HttpServletRequest req, val walletId = maybeAddress.orElseThrow(); - val cipBody = cipVerificationResult.getMessage(MessageFormat.TEXT); + var cipBody = cipVerificationResult.getMessage(MessageFormat.TEXT); + if (cipVerificationResult.isHashed() && payloadM.isEmpty()) { + val problem = Problem.builder() + .withTitle("HASHED_CONTENT_NO_PAYLOAD") + .withDetail("Payload was not sent along with the request and CIP-30 signature contains is hashed!") + .withStatus(BAD_REQUEST) + .build(); + + sendBackProblem(objectMapper, res, problem); + return; + } + + if (cipVerificationResult.isHashed()) { + val cipBodyHash = cipVerificationResult.getMessage(MessageFormat.HEX); + val payload = payloadM.orElseThrow(); + + val payloadHash = encodeHexString(blake2bHash224(decodeHexString(payload))); + + if (!cipBodyHash.equals(payloadHash)) { + val problem = Problem.builder() + .withTitle("CIP_30_HASH_MISMATCH") + .withDetail("Signed hash does not match our precalculated hash!") + .withStatus(BAD_REQUEST) + .build(); + + sendBackProblem(objectMapper, res, problem); + return; + } + + cipBody = new String(decodeHexString(payload)); // flip cipBody to be payload for further processing + } val cip93EnvelopeE = jsonService.decodeGenericCIP93(cipBody); if (cip93EnvelopeE.isEmpty()) { @@ -318,6 +351,7 @@ protected void doFilterInternal(HttpServletRequest req, .web3CommonDetails(commonWeb3Details) .envelope(genericEnvelope) .signedCIP30(signedWeb3Request) + .payload(cipBody) .cip30VerificationResult(cipVerificationResult) .build(); diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/KeriWeb3Details.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/KeriWeb3Details.java index 02168f0e1..0cec0c966 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/KeriWeb3Details.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/KeriWeb3Details.java @@ -40,8 +40,8 @@ public String getSignature() { } @Override - public Optional getPayload() { - return Optional.of(signedKERI.getPayload()); + public String getPayload() { + return signedKERI.getPayload(); } @Override diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/Web3ConcreteDetails.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/Web3ConcreteDetails.java index 290ab3401..f041fa279 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/Web3ConcreteDetails.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/auth/web3/Web3ConcreteDetails.java @@ -16,7 +16,7 @@ public interface Web3ConcreteDetails { String getSignature(); - Optional getPayload(); + String getPayload(); Optional getPublicKey(); diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/vote/DefaultVoteService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/vote/DefaultVoteService.java index 416870d06..11b8f816c 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/vote/DefaultVoteService.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/vote/DefaultVoteService.java @@ -28,9 +28,9 @@ import org.zalando.problem.Problem; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; -import java.util.Objects; import static com.bloxbean.cardano.client.util.HexUtil.encodeHexString; import static org.cardano.foundation.voting.domain.VoteReceipt.Status.*; @@ -306,7 +306,7 @@ public Either castVote(Web3AuthenticationToken web3Authentication existingVote.setVotedAtSlot(castVote.getVotedAtSlot()); existingVote.setWalletType(walletType); existingVote.setSignature(concreteDetails.getSignature()); - existingVote.setPayload(concreteDetails.getPayload()); + existingVote.setPayload(Optional.of(concreteDetails.getPayload())); existingVote.setPublicKey(concreteDetails.getPublicKey()); return Either.right(voteRepository.saveAndFlush(existingVote)); @@ -321,7 +321,7 @@ public Either castVote(Web3AuthenticationToken web3Authentication vote.setWalletType(walletType); vote.setVotedAtSlot(castVote.getVotedAtSlot()); vote.setSignature(concreteDetails.getSignature()); - vote.setPayload(concreteDetails.getPayload()); + vote.setPayload(Optional.of(concreteDetails.getPayload())); vote.setPublicKey(concreteDetails.getPublicKey()); vote.setIdNumericHash(UUID.fromString(voteId).hashCode() & 0xFFFFFFF); @@ -412,7 +412,7 @@ public Either castVote(Web3AuthenticationToken web3Authentication } private Either unwrapViewVoteReceiptEnvelope(Web3ConcreteDetails concreteDetails) { - val signedJson = concreteDetails.getSignedJson(); + val signedJson = concreteDetails.getPayload(); switch (concreteDetails) { case CardanoWeb3Details cardanoWeb3Details -> { @@ -455,7 +455,7 @@ private Either unwrapViewVoteReceiptEnvelope(W } private Either unwrapCastCoteEnvelope(Web3ConcreteDetails concreteDetails) { - val signedJson = concreteDetails.getSignedJson(); + val signedJson = concreteDetails.getPayload(); switch (concreteDetails) { case CardanoWeb3Details cardanoWeb3Details -> { diff --git a/backend-services/voting-app/src/test/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3FilterTest.java b/backend-services/voting-app/src/test/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3FilterTest.java index bab5c501b..ac9d5003d 100644 --- a/backend-services/voting-app/src/test/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3FilterTest.java +++ b/backend-services/voting-app/src/test/java/org/cardano/foundation/voting/service/auth/web3/CardanoWeb3FilterTest.java @@ -1,5 +1,6 @@ package org.cardano.foundation.voting.service.auth.web3; +import com.bloxbean.cardano.client.util.HexUtil; import com.fasterxml.jackson.databind.ObjectMapper; import io.vavr.control.Either; import jakarta.servlet.FilterChain; @@ -30,8 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.cardano.foundation.voting.domain.ChainNetwork.*; -import static org.cardano.foundation.voting.resource.Headers.X_Ballot_PublicKey; -import static org.cardano.foundation.voting.resource.Headers.X_Ballot_Signature; +import static org.cardano.foundation.voting.resource.Headers.*; import static org.cardano.foundation.voting.service.auth.LoginSystem.CARDANO_CIP93; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -48,8 +48,7 @@ class CardanoWeb3FilterTest { private ExpirationService expirationService; private ChainFollowerClient chainFollowerClient; private LoginSystemDetector loginSystemDetector; - - private final ChainNetwork chainNetworkStartedOn = PREPROD; + private ChainNetwork chainNetworkStartedOn = PREPROD; @BeforeEach void setUp() throws IOException { @@ -579,4 +578,108 @@ void doFilterInternal_shouldAuthenticate_whenAllConditionsMet() throws ServletEx assertThat(cardanoDetails.getEnvelope()).isNotNull(); } + @Test + void doFilterInternal_shouldAuthenticate_whenAllConditionsMetWithHashedContent() throws ServletException, IOException { + chainNetworkStartedOn = MAIN; + filter = new CardanoWeb3Filter(jsonService, expirationService, objectMapper, chainFollowerClient, chainNetworkStartedOn, loginSystemDetector); + + val payloadAsHex = "7b22616374696f6e223a224c4f47494e222c22616374696f6e54657874223a224c6f67696e222c2264617461223a7b226576656e74223a2243415244414e4f5f53554d4d49545f4157415244535f32303234222c226e6574776f726b223a224d41494e222c22726f6c65223a22564f544552222c2277616c6c65744964223a227374616b6531757970617970326e797a793636746d637a36796a757468353970796d3064663833726a706b30373538666871726e6371387663647a222c2277616c6c657454797065223a2243415244414e4f227d2c22736c6f74223a22313336303638393432227d"; + //{"action":"LOGIN","actionText":"Login","data":{"event":"CARDANO_SUMMIT_AWARDS_2024","network":"MAIN","role":"VOTER","walletId":"stake1uypayp2nyzy66tmcz6yjuth59pym0df83rjpk0758fhqrncq8vcdz","walletType":"CARDANO"},"slot":"136068942"} + + val genericEnvelope = CIP93Envelope.>builder() + .action("LOGIN") + .slot("136068942") + .data(Map.of( + "event", "CARDANO_SUMMIT_AWARDS_2024", + "walletId", "stake1uypayp2nyzy66tmcz6yjuth59pym0df83rjpk0758fhqrncq8vcdz", + "walletType", WalletType.CARDANO.name(), + "network", "MAIN", + "role", "VOTER" + ) + ) + .build(); + + when(loginSystemDetector.detect(request)).thenReturn(Optional.of(CARDANO_CIP93)); + when(request.getHeader(X_Ballot_Signature)).thenReturn("84582aa201276761646472657373581de103d205532089ad2f7816892e2ef42849b7b52788e41b3fd43a6e01cfa166686173686564f5581c1c1afc33a1ed48205eadcbbda2fc8e61442af2e04673616f21b7d0385840954858f672e9ca51975655452d79a8f106011e9535a2ebfb909f7bbcce5d10d246ae62df2da3a7790edd8f93723cbdfdffc5341d08135b1a40e7a998e8b2ed06"); + when(request.getHeader(X_Ballot_Payload)).thenReturn(payloadAsHex); + when(request.getHeader(X_Ballot_PublicKey)).thenReturn("a4010103272006215820c13745be35c2dfc3fa9523140030dda5b5346634e405662b1aae5c61389c55b3"); + + val cip30VerificationResult = mock(Cip30VerificationResult.class); + when(cip30VerificationResult.isValid()).thenReturn(true); + when(cip30VerificationResult.getAddress(any())).thenReturn(Optional.of("stake1uypayp2nyzy66tmcz6yjuth59pym0df83rjpk0758fhqrncq8vcdz")); + + when(chainFollowerClient.getChainTip()).thenReturn(Either.right( + new ChainFollowerClient.ChainTipResponse("hash", 512, 136068942, true, MAIN)) + ); + + when(chainFollowerClient.getEventDetails(any())).thenReturn(Either.right(Optional.of(mock(ChainFollowerClient.EventDetailsResponse.class)))); + + when(jsonService.decodeGenericCIP93(any())).thenReturn(Either.right(genericEnvelope)); + + filter.doFilterInternal(request, response, chain); + + verify(chain, times(1)).doFilter(request, response); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo("stake1uypayp2nyzy66tmcz6yjuth59pym0df83rjpk0758fhqrncq8vcdz"); + + val cardanoDetails = (CardanoWeb3Details) SecurityContextHolder.getContext().getAuthentication().getDetails(); + + assertThat(cardanoDetails.getSignedCIP30().getSignature()).isEqualTo("84582aa201276761646472657373581de103d205532089ad2f7816892e2ef42849b7b52788e41b3fd43a6e01cfa166686173686564f5581c1c1afc33a1ed48205eadcbbda2fc8e61442af2e04673616f21b7d0385840954858f672e9ca51975655452d79a8f106011e9535a2ebfb909f7bbcce5d10d246ae62df2da3a7790edd8f93723cbdfdffc5341d08135b1a40e7a998e8b2ed06"); + assertThat(cardanoDetails.getSignedCIP30().getPublicKey()).isEqualTo(Optional.of("a4010103272006215820c13745be35c2dfc3fa9523140030dda5b5346634e405662b1aae5c61389c55b3")); + assertThat(cardanoDetails.getWeb3CommonDetails().getAction()).isEqualTo(Web3Action.LOGIN); + assertThat(cardanoDetails.getWeb3CommonDetails().getNetwork()).isEqualTo(MAIN); + assertThat(cardanoDetails.getPayload()).isEqualTo(new String(HexUtil.decodeHexString(payloadAsHex))); + assertThat(cardanoDetails.getEnvelope()).isNotNull(); + } + + // CIP30 is a data sign with hash only but hashes do not properly match + @Test + void doFilter_SignedHashFailure() throws ServletException, IOException { + chainNetworkStartedOn = MAIN; + filter = new CardanoWeb3Filter(jsonService, expirationService, objectMapper, chainFollowerClient, chainNetworkStartedOn, loginSystemDetector); + + //{"action":"LOGIN","actionText":"Login","data":{"event":"CARDANO_SUMMIT_AWARDS_2024","network":"MAIN","role":"VOTER","walletId":"stake1uypayp2nyzy66tmcz6yjuth59pym0df83rjpk0758fhqrncq8vcdz","walletType":"CARDANO"},"slot":"136068943"} + + val genericEnvelope = CIP93Envelope.>builder() + .action("LOGIN") + .slot("136068943") + .data(Map.of( + "event", "CARDANO_SUMMIT_AWARDS_2024", + "walletId", "stake1uypayp2nyzy66tmcz6yjuth59pym0df83rjpk0758fhqrncq8vcdz", + "walletType", WalletType.CARDANO.name(), + "network", "MAIN", + "role", "VOTER" + ) + ) + .build(); + + when(loginSystemDetector.detect(request)).thenReturn(Optional.of(CARDANO_CIP93)); + when(request.getHeader(X_Ballot_Signature)).thenReturn("84582aa201276761646472657373581de103d205532089ad2f7816892e2ef42849b7b52788e41b3fd43a6e01cfa166686173686564f5581c1c1afc33a1ed48205eadcbbda2fc8e61442af2e04673616f21b7d0385840954858f672e9ca51975655452d79a8f106011e9535a2ebfb909f7bbcce5d10d246ae62df2da3a7790edd8f93723cbdfdffc5341d08135b1a40e7a998e8b2ed06"); + when(request.getHeader(X_Ballot_Payload)).thenReturn("7B22616374696F6E223A224C4F47494E222C22616374696F6E54657874223A224C6F67696E222C2264617461223A7B226576656E74223A2243415244414E4F5F53554D4D49545F4157415244535F32303234222C226E6574776F726B223A224D41494E222C22726F6C65223A22564F544552222C2277616C6C65744964223A227374616B6531757970617970326E797A793636746D637A36796A757468353970796D3064663833726A706B30373538666871726E6371387663647A222C2277616C6C657454797065223A2243415244414E4F227D2C22736C6F74223A22313336303638393433227D"); + when(request.getHeader(X_Ballot_PublicKey)).thenReturn("a4010103272006215820c13745be35c2dfc3fa9523140030dda5b5346634e405662b1aae5c61389c55b3"); + + val cip30VerificationResult = mock(Cip30VerificationResult.class); + when(cip30VerificationResult.isValid()).thenReturn(true); + when(cip30VerificationResult.getAddress(any())).thenReturn(Optional.of("stake1uypayp2nyzy66tmcz6yjuth59pym0df83rjpk0758fhqrncq8vcdz")); + + when(chainFollowerClient.getChainTip()).thenReturn(Either.right( + new ChainFollowerClient.ChainTipResponse("hash", 512, 136068943, true, MAIN)) + ); + + when(chainFollowerClient.getEventDetails(any())).thenReturn(Either.right(Optional.of(mock(ChainFollowerClient.EventDetailsResponse.class)))); + + when(jsonService.decodeGenericCIP93(any())).thenReturn(Either.right(genericEnvelope)); + + filter.doFilterInternal(request, response, chain); + + val problemCaptor = ArgumentCaptor.forClass(Problem.class); + + verify(objectMapper, times(1)).writeValueAsString(problemCaptor.capture()); + val capturedProblem = problemCaptor.getValue(); + + assertThat(capturedProblem.getTitle()).isEqualTo("CIP_30_HASH_MISMATCH"); + assertThat(capturedProblem.getStatus()).isEqualTo(BAD_REQUEST); + } + }