diff --git a/src/main/kotlin/software/tice/wallet/attestation/KeyConfiguration.kt b/src/main/kotlin/software/tice/wallet/attestation/KeyConfiguration.kt new file mode 100644 index 0000000..9286727 --- /dev/null +++ b/src/main/kotlin/software/tice/wallet/attestation/KeyConfiguration.kt @@ -0,0 +1,28 @@ +package software.tice.wallet.attestation + +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.util.* + +@Configuration +class KeyConfiguration { + + @Value("\${private.key}") + private lateinit var privateKey: String + + @Bean + fun decodePrivateKey(): PrivateKey { + val pem = privateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + val keyBytes = Base64.getDecoder().decode(pem) + val keySpec = PKCS8EncodedKeySpec(keyBytes) + val keyFactory = KeyFactory.getInstance("EC") + return keyFactory.generatePrivate(keySpec) + } +} \ No newline at end of file diff --git a/src/main/kotlin/software/tice/wallet/attestation/controllers/IssuerApi.kt b/src/main/kotlin/software/tice/wallet/attestation/controllers/IssuerApi.kt index c7c3c66..88b6448 100644 --- a/src/main/kotlin/software/tice/wallet/attestation/controllers/IssuerApi.kt +++ b/src/main/kotlin/software/tice/wallet/attestation/controllers/IssuerApi.kt @@ -6,7 +6,7 @@ import org.springframework.web.bind.annotation.* import software.tice.wallet.attestation.requests.ValidationRequest @RestController -@RequestMapping("attestation") +@RequestMapping("wallet") class IssuerApi { @PostMapping("/validation") diff --git a/src/main/kotlin/software/tice/wallet/attestation/controllers/WalletApi.kt b/src/main/kotlin/software/tice/wallet/attestation/controllers/WalletApi.kt index 9a26aee..83e98ba 100644 --- a/src/main/kotlin/software/tice/wallet/attestation/controllers/WalletApi.kt +++ b/src/main/kotlin/software/tice/wallet/attestation/controllers/WalletApi.kt @@ -1,5 +1,11 @@ package software.tice.wallet.attestation.controllers +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.MediaType import org.springframework.web.bind.annotation.* import software.tice.wallet.attestation.requests.AttestationRequest import software.tice.wallet.attestation.requests.NonceRequest @@ -7,21 +13,34 @@ import software.tice.wallet.attestation.responses.AttestationResponse import software.tice.wallet.attestation.responses.NonceResponse import software.tice.wallet.attestation.services.WalletApiService + @RestController -@RequestMapping("attestation") +@RequestMapping("wallet") class WalletApi(val walletApiService: WalletApiService) { - @PostMapping("/nonces") + @PostMapping fun requestNonces(@RequestBody request: NonceRequest): NonceResponse { - return walletApiService.requestNonces(request.walletInstanceId) + return walletApiService.requestNonces(request.walletId) } - @PostMapping("/request/{id}") + @PostMapping("/{walletId}/attestation") + @Operation(summary = "Submits an attestation request for a given wallet ID.") + + @ApiResponse(description = "Wallet attestation issued", responseCode = "200") + @ApiResponse( + description = "Wallet not found", responseCode = "404", content = [Content( + mediaType = MediaType.TEXT_PLAIN_VALUE, schema = Schema(implementation = String::class) + )] + ) + @ApiResponse( + description = "Public Key wrong", responseCode = "400", content = [Content( + mediaType = MediaType.TEXT_PLAIN_VALUE, schema = Schema(implementation = String::class) + )] + ) fun requestAttestation( - @RequestBody request: AttestationRequest, - @PathVariable id: String + @RequestBody request: AttestationRequest, @PathVariable walletId: String ): AttestationResponse { - return walletApiService.requestAttestation(request, id) + return walletApiService.requestAttestation(request, walletId) } } \ No newline at end of file diff --git a/src/main/kotlin/software/tice/wallet/attestation/exceptions/ControllerAdvice.kt b/src/main/kotlin/software/tice/wallet/attestation/exceptions/ControllerAdvice.kt new file mode 100644 index 0000000..bcac4f1 --- /dev/null +++ b/src/main/kotlin/software/tice/wallet/attestation/exceptions/ControllerAdvice.kt @@ -0,0 +1,22 @@ +package software.tice.wallet.attestation.exceptions + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler + + +@ControllerAdvice +class GlobalControllerAdvice { + @ExceptionHandler(WalletNotFoundException::class) + fun handleWalletNotFound(ex: WalletNotFoundException): ResponseEntity = + ResponseEntity(ex.message, HttpStatus.NOT_FOUND) + + @ExceptionHandler(PopVerificationException::class) + fun handlePopVerificationFailed(ex: PopVerificationException): ResponseEntity = + ResponseEntity(ex.message, HttpStatus.UNAUTHORIZED) + + @ExceptionHandler(DecodingFailedException::class) + fun handleDecodingFailedException(ex: DecodingFailedException): ResponseEntity = + ResponseEntity(ex.message, HttpStatus.BAD_REQUEST) +} \ No newline at end of file diff --git a/src/main/kotlin/software/tice/wallet/attestation/exceptions/DecodingFailedException.kt b/src/main/kotlin/software/tice/wallet/attestation/exceptions/DecodingFailedException.kt new file mode 100644 index 0000000..b4011f2 --- /dev/null +++ b/src/main/kotlin/software/tice/wallet/attestation/exceptions/DecodingFailedException.kt @@ -0,0 +1,3 @@ +package software.tice.wallet.attestation.exceptions + +class DecodingFailedException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/software/tice/wallet/attestation/exceptions/PopVerificationException.kt b/src/main/kotlin/software/tice/wallet/attestation/exceptions/PopVerificationException.kt new file mode 100644 index 0000000..afdd150 --- /dev/null +++ b/src/main/kotlin/software/tice/wallet/attestation/exceptions/PopVerificationException.kt @@ -0,0 +1,3 @@ +package software.tice.wallet.attestation.exceptions + +class PopVerificationException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/software/tice/wallet/attestation/exceptions/WalletNotFoundException.kt b/src/main/kotlin/software/tice/wallet/attestation/exceptions/WalletNotFoundException.kt new file mode 100644 index 0000000..d2169ae --- /dev/null +++ b/src/main/kotlin/software/tice/wallet/attestation/exceptions/WalletNotFoundException.kt @@ -0,0 +1,3 @@ +package software.tice.wallet.attestation.exceptions + +class WalletNotFoundException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/software/tice/wallet/attestation/repositories/UserEntity.kt b/src/main/kotlin/software/tice/wallet/attestation/repositories/WalletEntity.kt similarity index 62% rename from src/main/kotlin/software/tice/wallet/attestation/repositories/UserEntity.kt rename to src/main/kotlin/software/tice/wallet/attestation/repositories/WalletEntity.kt index b3f7bc6..8ed4a49 100644 --- a/src/main/kotlin/software/tice/wallet/attestation/repositories/UserEntity.kt +++ b/src/main/kotlin/software/tice/wallet/attestation/repositories/WalletEntity.kt @@ -1,14 +1,16 @@ package software.tice.wallet.attestation.repositories import jakarta.persistence.* +import java.util.* @Entity(name = "users") -data class UserEntity( +data class WalletEntity( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long?, - var walletInstanceId: String, + var walletId: String, var popNonce: String?, - var keyAttestationNonce: String? + var keyAttestationNonce: String?, + var randomId: String? ) \ No newline at end of file diff --git a/src/main/kotlin/software/tice/wallet/attestation/repositories/UserRepository.kt b/src/main/kotlin/software/tice/wallet/attestation/repositories/WalletRepository.kt similarity index 59% rename from src/main/kotlin/software/tice/wallet/attestation/repositories/UserRepository.kt rename to src/main/kotlin/software/tice/wallet/attestation/repositories/WalletRepository.kt index f3501d3..58355c7 100644 --- a/src/main/kotlin/software/tice/wallet/attestation/repositories/UserRepository.kt +++ b/src/main/kotlin/software/tice/wallet/attestation/repositories/WalletRepository.kt @@ -5,5 +5,7 @@ import org.springframework.stereotype.Repository @Repository -interface UserRepository : JpaRepository +interface WalletRepository : JpaRepository { + fun findByWalletId(walletId: String): WalletEntity? +} diff --git a/src/main/kotlin/software/tice/wallet/attestation/requests/NonceRequest.kt b/src/main/kotlin/software/tice/wallet/attestation/requests/NonceRequest.kt index 8fbe07f..5bcc7e6 100644 --- a/src/main/kotlin/software/tice/wallet/attestation/requests/NonceRequest.kt +++ b/src/main/kotlin/software/tice/wallet/attestation/requests/NonceRequest.kt @@ -1,3 +1,3 @@ package software.tice.wallet.attestation.requests -data class NonceRequest(val walletInstanceId: String) \ No newline at end of file +data class NonceRequest(val walletId: String) \ No newline at end of file diff --git a/src/main/kotlin/software/tice/wallet/attestation/services/WalletApiService.kt b/src/main/kotlin/software/tice/wallet/attestation/services/WalletApiService.kt index 926271e..d858fd4 100644 --- a/src/main/kotlin/software/tice/wallet/attestation/services/WalletApiService.kt +++ b/src/main/kotlin/software/tice/wallet/attestation/services/WalletApiService.kt @@ -1,52 +1,98 @@ package software.tice.wallet.attestation.services +import io.jsonwebtoken.JwtException import io.jsonwebtoken.Jwts import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service -import software.tice.wallet.attestation.repositories.UserEntity -import software.tice.wallet.attestation.repositories.UserRepository +import org.springframework.transaction.annotation.Transactional +import software.tice.wallet.attestation.exceptions.DecodingFailedException +import software.tice.wallet.attestation.repositories.WalletEntity +import software.tice.wallet.attestation.repositories.WalletRepository import software.tice.wallet.attestation.requests.AttestationRequest import software.tice.wallet.attestation.responses.AttestationResponse import software.tice.wallet.attestation.responses.NonceResponse +import software.tice.wallet.attestation.exceptions.PopVerificationException +import software.tice.wallet.attestation.exceptions.WalletNotFoundException import java.security.KeyFactory -import java.security.spec.PKCS8EncodedKeySpec +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.X509EncodedKeySpec import java.util.* + @Service class WalletApiService @Autowired constructor( - @Value("\${private.key}") - private val privateKey: String, - private val userRepository: UserRepository, + private val privateKey: PrivateKey, + private val walletRepository: WalletRepository, + -) { - fun requestNonces(walletInstanceId: String): NonceResponse { + ) { + fun requestNonces(walletId: String): NonceResponse { val (popNonce, keyAttestationNonce) = List(2) { UUID.randomUUID().toString() } - val user = UserEntity( - walletInstanceId = walletInstanceId, - popNonce = popNonce, - keyAttestationNonce = keyAttestationNonce, - id = null - ) + val existingWallet = walletRepository.findByWalletId(walletId) - userRepository.save(user) - return NonceResponse(popNonce = popNonce, keyAttestationNonce = keyAttestationNonce ) + if (existingWallet != null) { + existingWallet.popNonce = popNonce + existingWallet.keyAttestationNonce = keyAttestationNonce + walletRepository.save(existingWallet) + } else { + val newWallet = WalletEntity( + walletId = walletId, popNonce = popNonce, keyAttestationNonce = keyAttestationNonce, id = null, randomId = null + ) + walletRepository.save(newWallet) + } + return NonceResponse(popNonce = popNonce, keyAttestationNonce = keyAttestationNonce) } fun requestAttestation(requestAttestation: AttestationRequest, id: String): AttestationResponse { - val privateKey = privateKey - val pem = privateKey - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") + val existingWallet = + walletRepository.findByWalletId(id) ?: throw WalletNotFoundException("Wallet with id $id not found") + + // <--- Start: check the PoP ---> + val publicKey: PublicKey = try { + decodePublicKey(requestAttestation.attestationPublicKey) + } catch (e: Exception) { + throw DecodingFailedException("Public Key could not be decoded") + } + + try { + val nonce: String? = Jwts.parser().verifyWith(publicKey).build() + .parseSignedClaims(requestAttestation.proofOfPossession).payload["nonce"] as? String + + if (nonce != existingWallet.popNonce) { + throw PopVerificationException("Nonce mismatch") + } + } catch (e: JwtException) { + throw PopVerificationException("Signature invalid") + } + // <--- End: check the PoP ---> - val decodedKey = Base64.getDecoder().decode(pem) + // <--- Start: throw away nonces and create random ID ---> + val randomId: String = UUID.randomUUID().toString() - val keySpec = PKCS8EncodedKeySpec(decodedKey) - val keyFactory = KeyFactory.getInstance("EC") - val privateKeyReloaded = keyFactory.generatePrivate(keySpec) + existingWallet.randomId = randomId + existingWallet.popNonce = null + existingWallet.keyAttestationNonce = null + + walletRepository.save(existingWallet) + + // <--- End: throw away nonces and create random ID ---> + + // <--- Start: create walletAttestation ---> + val walletAttestation: String = + Jwts.builder().subject("Joe").claim("publicKey", requestAttestation.attestationPublicKey) + .claim("randomId", randomId).signWith(privateKey).compact() + // <--- End: create walletAttestation ---> - val walletAttestation: String = Jwts.builder().subject("Joe").signWith(privateKeyReloaded).compact() return AttestationResponse(walletAttestation) } + + fun decodePublicKey(key: String): PublicKey { + val pem = key.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "") + val keyBytes = Base64.getDecoder().decode(pem) + val keySpec = X509EncodedKeySpec(keyBytes) + val keyFactory = KeyFactory.getInstance("EC") + return keyFactory.generatePublic(keySpec) + } } \ No newline at end of file diff --git a/src/test/kotlin/software/tice/wallet/attestation/services/WalletApiServiceTests.kt b/src/test/kotlin/software/tice/wallet/attestation/services/WalletApiServiceTests.kt index a38e0d2..bc07ab0 100644 --- a/src/test/kotlin/software/tice/wallet/attestation/services/WalletApiServiceTests.kt +++ b/src/test/kotlin/software/tice/wallet/attestation/services/WalletApiServiceTests.kt @@ -2,63 +2,170 @@ package software.tice.wallet.attestation.services import io.jsonwebtoken.Jwts import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.mockito.* import org.mockito.Mockito.verify -import software.tice.wallet.attestation.repositories.UserEntity -import software.tice.wallet.attestation.repositories.UserRepository +import org.mockito.Mockito.`when` +import software.tice.wallet.attestation.exceptions.DecodingFailedException +import software.tice.wallet.attestation.repositories.WalletEntity +import software.tice.wallet.attestation.repositories.WalletRepository import software.tice.wallet.attestation.requests.AttestationRequest +import software.tice.wallet.attestation.exceptions.PopVerificationException +import software.tice.wallet.attestation.exceptions.WalletNotFoundException import java.security.KeyPair +import java.security.PrivateKey import java.util.* +import java.util.UUID.randomUUID import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.random.Random +import kotlin.test.assertNotNull internal class WalletApiServiceTests { - @Mock - private lateinit var userRepository: UserRepository - - private lateinit var privateKey: String + private lateinit var walletRepository: WalletRepository private lateinit var walletApiService: WalletApiService - @Captor - private lateinit var userCaptor: ArgumentCaptor + private lateinit var walletId: String + + private lateinit var privateKey: PrivateKey private val keyPair: KeyPair = Jwts.SIG.ES256.keyPair().build() + private val internalWalletId: Long = Random.nextLong() + + @Captor + private lateinit var walletCaptor: ArgumentCaptor @BeforeEach fun setup() { MockitoAnnotations.openMocks(this) - privateKey = Base64.getEncoder().encodeToString(keyPair.private.encoded) - walletApiService = WalletApiService(privateKey, userRepository) - } - - @Test - fun `should return correct NonceResponse`() { - val walletInstanceId = "f74813c9-3435-4028-8e0c-018dd34d3b60" - - val response = walletApiService.requestNonces(walletInstanceId) + walletId = randomUUID().toString() + privateKey = keyPair.private - verify(userRepository).save(userCaptor.capture()) - val savedUser = userCaptor.value - assertEquals(walletInstanceId, savedUser.walletInstanceId) - assertEquals(response.popNonce, savedUser.popNonce) - assertEquals(response.keyAttestationNonce, savedUser.keyAttestationNonce) + walletApiService = WalletApiService(privateKey, walletRepository) } + @Nested + inner class RequestNoncesTests { + @Test + fun `should return correct NonceResponse and update existing user`() { + val existingWallet = WalletEntity(internalWalletId, walletId, "popNonce", "keyAttestation", randomId = null) + `when`(walletRepository.findByWalletId(walletId)).thenReturn(existingWallet) + + val response = walletApiService.requestNonces(walletId) + + verify(walletRepository).save(walletCaptor.capture()) + val savedwallet = walletCaptor.value + assertEquals(internalWalletId, savedwallet.id) + assertEquals(walletId, savedwallet.walletId) + assertEquals(response.popNonce, savedwallet.popNonce) + assertEquals(response.keyAttestationNonce, savedwallet.keyAttestationNonce) + } + + @Test + fun `should return correct NonceResponse and add new user`() { + `when`(walletRepository.findByWalletId(walletId)).thenReturn(null) + + val response = walletApiService.requestNonces(walletId) + + verify(walletRepository).save(walletCaptor.capture()) + val newWallet = walletCaptor.value + assertNull(newWallet.id) + assertEquals(walletId, newWallet.walletId) + assertEquals(response.popNonce, newWallet.popNonce) + assertEquals(response.keyAttestationNonce, newWallet.keyAttestationNonce) + + } + } - @Test - fun `should return correct wallet attestation`() { - val request = AttestationRequest("PUBLIC_KEY","POP","KEY_ATTESTATION", "APP_ATTESTATION") - val walletInstanceId = "f74813c9-3435-4028-8e0c-018dd34d3b60" - - val response = walletApiService.requestAttestation(request, walletInstanceId) - - val parser = Jwts.parser() - .verifyWith(keyPair.public) - .build() - assertEquals(parser.parseSignedClaims(response.walletAttestation).payload.subject, "Joe") + @Nested + inner class RequestAttestationTests { + @Test + fun `should return correct wallet attestation`() { + val publicKey = Base64.getEncoder().encodeToString(keyPair.public.encoded) + val popNonce = randomUUID().toString() + val mockPop = Jwts.builder().claim("nonce", popNonce).signWith(privateKey).compact() + val existingWallet = WalletEntity(internalWalletId, walletId, popNonce, "keyAttestation", randomId = null) + `when`(walletRepository.findByWalletId(walletId)).thenReturn(existingWallet) + val request = AttestationRequest(publicKey, mockPop, "KEY_ATTESTATION", "APP_ATTESTATION") + + val response = walletApiService.requestAttestation(request, walletId) + + verify(walletRepository).save(walletCaptor.capture()) + val newWallet = walletCaptor.value + val claims = Jwts.parser().verifyWith(keyPair.public).build().parseSignedClaims(response.walletAttestation) + assertEquals(claims.payload.subject, "Joe") + assertEquals(claims.payload["publicKey"], publicKey) + assertNotNull(newWallet.randomId) + assertNull(newWallet.popNonce) + assertNull(newWallet.keyAttestationNonce) + } + + @Test + fun `should throw WalletNotFoundException if wallet can not be found`() { + val publicKey = Base64.getEncoder().encodeToString(keyPair.public.encoded) + val request = AttestationRequest(publicKey, "POP", "KEY_ATTESTATION", "APP_ATTESTATION") + `when`(walletRepository.findByWalletId(walletId)).thenReturn(null) + + val exception = assertThrows { + walletApiService.requestAttestation(request, walletId) + } + assertEquals("Wallet with id $walletId not found", exception.message) + } + + @Test + fun `should throw DecodingFailedException if public key can not be decoded`() { + val publicKey = Base64.getEncoder().encodeToString(keyPair.public.encoded) + val corruptedPublicKey = "$publicKey???" + val popNonce = randomUUID().toString() + val mockPop = Jwts.builder().claim("nonce", popNonce).signWith(privateKey).compact() + val existingWallet = WalletEntity(internalWalletId, walletId, popNonce, "keyAttestation", randomId = null) + `when`(walletRepository.findByWalletId(walletId)).thenReturn(existingWallet) + val request = AttestationRequest(corruptedPublicKey, mockPop, "KEY_ATTESTATION", "APP_ATTESTATION") + + val exception = assertThrows { + walletApiService.requestAttestation(request, walletId) + } + assertEquals("Public Key could not be decoded", exception.message) + } + + + @Test + fun `should throw PopVerificationException if nonce does not match`() { + val publicKey = Base64.getEncoder().encodeToString(keyPair.public.encoded) + val popNonceOne = randomUUID().toString() + val popNonceTwo = randomUUID().toString() + val mockPop = Jwts.builder().claim("nonce", popNonceOne).signWith(privateKey).compact() + val existingWallet = WalletEntity(internalWalletId, walletId, popNonceTwo, "keyAttestation", randomId = null) + `when`(walletRepository.findByWalletId(walletId)).thenReturn(existingWallet) + + val request = AttestationRequest(publicKey, mockPop, "KEY_ATTESTATION", "APP_ATTESTATION") + + val exception = assertThrows { + walletApiService.requestAttestation(request, walletId) + } + assertEquals("Nonce mismatch", exception.message) + } + + @Test + fun `should throw PopVerificationException if signature is invalid`() { + val publicKey = Base64.getEncoder().encodeToString(keyPair.public.encoded) + val popNonce = randomUUID().toString() + val maliciousPrivateKey = Jwts.SIG.ES256.keyPair().build().private + val mockPop = Jwts.builder().claim("nonce", popNonce).signWith(maliciousPrivateKey).compact() + val existingWallet = WalletEntity(internalWalletId, walletId, popNonce, "keyAttestation", randomId = null) + `when`(walletRepository.findByWalletId(walletId)).thenReturn(existingWallet) + val request = AttestationRequest(publicKey, mockPop, "KEY_ATTESTATION", "APP_ATTESTATION") + + val exception = assertThrows { + walletApiService.requestAttestation(request, walletId) + } + assertEquals("Signature invalid", exception.message) + } } }