Last active
December 30, 2023 20:24
-
-
Save libetl/d5ace216bc5ee6e7cad84c4cefd4f98d to your computer and use it in GitHub Desktop.
How Client-Side Encryption works
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.mycompany.myservice.domain.payment.card | |
import com.mycompany.myservice.infra.serialization.CreditCardNumberDeserializer | |
import com.fasterxml.jackson.annotation.JsonCreator | |
import com.fasterxml.jackson.annotation.JsonValue | |
import com.fasterxml.jackson.databind.annotation.JsonDeserialize | |
import java.time.YearMonth | |
data class CreditCard( | |
val holderName: String = "UNKNOWN", | |
val expirationDate: YearMonth? = null, | |
val cardVerificationValue: VerificationValue? = null, | |
val cardNumber: Number | |
) { | |
data class VerificationValue @JsonCreator(mode = JsonCreator.Mode.DELEGATING) constructor( | |
@get:JsonValue val value: Int | |
) | |
@JsonDeserialize(using = CreditCardNumberDeserializer::class) | |
data class Number constructor(@get:JsonValue val value: String) { | |
companion object { | |
val CREDIT_CARD_NUMBER_REGEX = Regex("[0-9X*]{10,20}") | |
val CLEAR_TEXT_CARD_NUMBER_REGEX = Regex("[0-9]{10,20}") | |
} | |
init { | |
assert(value.matches(CREDIT_CARD_NUMBER_REGEX)) { "credit card number is invalid" } | |
assert(!value.matches(CLEAR_TEXT_CARD_NUMBER_REGEX) || luhn == value.last().toString().toInt()) { | |
"credit card number : valid format but incorrect value" | |
} | |
} | |
private val luhn | |
get() = value | |
.slice(0 until value.length - 1) | |
.reversed() | |
.mapIndexed { index, char -> | |
val even = index % 2 == 0 | |
char.toString().toInt().let { | |
if (even) it shl 1 else it | |
}.let { | |
if (it > 9) it - 9 else it | |
} | |
}.sum().let { | |
(10 - it % 10) % 10 | |
} | |
override fun toString() = value | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.mycompany.myservice.infra.serialization | |
import com.mycompany.myservice.domain.payment.card.CreditCard | |
import com.mycompany.myservice.infra.security.GetTwoMostRecentKeypairs | |
import com.fasterxml.jackson.core.JsonParseException | |
import com.fasterxml.jackson.core.JsonParser | |
import com.fasterxml.jackson.databind.DeserializationContext | |
import com.fasterxml.jackson.databind.JsonDeserializer | |
import com.fasterxml.jackson.databind.JsonNode | |
import java.io.IOException | |
import java.nio.charset.Charset | |
import java.security.KeyFactory | |
import java.security.spec.MGF1ParameterSpec | |
import java.security.spec.PKCS8EncodedKeySpec | |
import java.util.Base64 | |
import javax.crypto.Cipher | |
import javax.crypto.spec.OAEPParameterSpec | |
import javax.crypto.spec.PSource | |
class CreditCardNumberDeserializer( | |
private val getTwoMostRecentKeypairs: GetTwoMostRecentKeypairs | |
) : JsonDeserializer<CreditCard.Number>() { | |
@Throws(IOException::class) | |
override fun deserialize( | |
p: JsonParser, | |
ctxt: DeserializationContext | |
): CreditCard.Number { | |
val jsonNode = p.readValueAsTree<JsonNode>() | |
if (!jsonNode.isLong && !jsonNode.isTextual) | |
throw JsonParseException("card number can only be extracted out of string") | |
val value = if (jsonNode.isLong) "${jsonNode.longValue()}" else jsonNode.textValue() | |
// first attempt if the card is in clear text | |
runCatching { CreditCard.Number(value) }.map { return it } | |
val (lastKeypair, previousKeypair) = getTwoMostRecentKeypairs.fromTheStore() | |
val encryptedCardNumber = Base64.getDecoder().decode( | |
value.replace(Regex("-"), "+") | |
.replace(Regex("_"), "/")) | |
// second attempt with latest keypair | |
runCatching { | |
instantiateAsCardNumberUsing(lastKeypair.privateKey, encryptedCardNumber) | |
} | |
.map { return it } | |
// last attempt with previous keypair (suppose the keys rotated during the checkout) | |
return instantiateAsCardNumberUsing(previousKeypair.privateKey, encryptedCardNumber) | |
} | |
private fun instantiateAsCardNumberUsing( | |
privateKeyText: String, | |
encryptedCardNumber: ByteArray | |
): CreditCard.Number { | |
val privateKey = KeyFactory.getInstance("RSA").generatePrivate( | |
PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyText), "rsa2048") | |
) | |
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding").apply { | |
init(Cipher.DECRYPT_MODE, privateKey, | |
OAEPParameterSpec("SHA-256", "MGF1", | |
MGF1ParameterSpec("SHA-256"), | |
PSource.PSpecified.DEFAULT)) | |
} | |
return CreditCard.Number(cipher.doFinal(encryptedCardNumber).toString(Charset.defaultCharset())) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const publicKeyBinary = atob((await (await fetch('/my-service/client-side-encryption')).json()).public_key) | |
const publicKey = await window.crypto.subtle.importKey( | |
'spki', | |
new Uint8Array( | |
Array(publicKeyBinary.length) | |
.fill(0) | |
.map((_, i) => publicKeyBinary.charCodeAt(i)), | |
).buffer, | |
{ | |
name: 'RSA-OAEP', | |
hash: 'SHA-256', | |
}, | |
true, | |
['encrypt'], | |
) | |
async function encryptWithPublicKey(value: string): Promise<string> { | |
return btoa( | |
String.fromCharCode(...new Uint8Array( | |
await crypto.subtle.encrypt('RSA-OAEP', | |
publicKey, | |
new TextEncoder().encode(value))))) | |
} | |
async function sendCreditCardDetails({ | |
cardNumber, | |
expirationDate, | |
cardVerificationValue, | |
cardHolderName | |
}: CreditCard): Promise<Response> { | |
return await fetch('/my-service/card-details', { | |
method: 'POST', | |
credentials: 'same-origin', | |
headers: { | |
'Content-Type': 'application/json' | |
}, | |
body: JSON.stringify({ | |
cardNumber: await encryptWithPublicKey(cardNumber), | |
expirationDate, | |
cardVerificationValue, | |
cardHolderName | |
}) | |
}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
openssl genrsa -out /tmp/private_key.pem 1024 2>/dev/null | |
openssl rsa -in /tmp/private_key.pem -pubout 2>/dev/null > /tmp/public_key.pem | |
PRIVATE_KEY=$(echo $(cat /tmp/private_key.pem | sed '/^-----/d') | tr -d ' ') | |
PUBLIC_KEY=$(echo $(cat /tmp/public_key.pem | sed '/^-----/d') | tr -d ' ') | |
echo "{\"private_key\":\"$PRIVATE_KEY\",\"public_key\":\"$PUBLIC_KEY\"}" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.mycompany.myservice.infra.security | |
import com.mycompany.myservice.infra.repository.KeypairRepository | |
import com.mycompany.myservice.domain.transverse.Keypair | |
class GetTwoMostRecentKeypairs(private val respository: KeypairRepository) { | |
fun fromTheStore(): Pair<Keypair, Keypair> { | |
val values = respository.getValues() | |
val maxVersion = values.maxOf { it["version"]!![0]!!.toInt() } | |
return (values.find { it["version"]!![0]!!.toInt() == maxVersion }!!.value as Keypair) to | |
(values.find { it["version"]!![0]!!.toInt() == maxVersion - 1 }!!.value as Keypair) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.mycompany.myservice.domain.transverse | |
import com.fasterxml.jackson.annotation.JsonProperty | |
data class Keypair( | |
@JsonProperty("public_key") val publicKey: String, | |
@JsonProperty("private_key") val privateKey: String | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.mycompany.myservice.infra.controller | |
import com.mycompany.myservice.actions.StoreCardDetails | |
import com.mycompany.myservice.domain.payment.card.CreditCard | |
import com.mycompany.myservice.infra.security.GetTwoMostRecentKeypairs | |
import org.springframework.http.HttpStatus | |
import org.springframework.http.MediaType | |
import org.springframework.http.ResponseEntity | |
import org.springframework.web.bind.annotation.GetMapping | |
import org.springframework.web.bind.annotation.PostMapping | |
import org.springframework.web.bind.annotation.RequestBody | |
import org.springframework.web.bind.annotation.RequestMapping | |
import org.springframework.web.bind.annotation.ResponseBody | |
import org.springframework.web.bind.annotation.RestController | |
@RestController("my-service") | |
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"]) | |
class MyServiceController( | |
private val getTwoMostRecentKeypairs: GetTwoMostRecentKeypairs, | |
private val storeCardDetails: StoreCardDetails | |
) { | |
@ResponseBody | |
@GetMapping("/client-side-encryption") | |
fun getPublicKey(): ResponseEntity<Map<String, Any?>> { | |
return ResponseEntity.ok(mapOf( | |
"public_key" to getTwoMostRecentKeypairs.fromTheStore().first.publicKey | |
)) | |
} | |
@ResponseBody | |
@PostMapping(value = ["/card-details"]) | |
fun sendCreditCardDetails( | |
@RequestBody creditCard: CreditCard | |
) = | |
ResponseEntity( | |
storeCardDetails.using(creditCard), | |
HttpStatus.CREATED | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.mycompany.myservice.actions | |
import com.mycompany.myservice.domain.payment.card.CreditCard | |
import org.slf4j.LoggerFactory | |
class StoreCardDetails(cardRepository: CardRepository) { | |
companion object { | |
private val LOGGER = LoggerFactory.getLogger(StoreCardDetails::class.java) | |
} | |
fun using(card: CreditCard): StorageStatus { | |
try { | |
cardRepository.encryptAndSave(card) | |
return StorageStatus.STORED | |
} catch (e: RepositoryException) { | |
LOGGER.error("Card wasn't saved in the repository", e) | |
return StorageStatus.FAILED | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment