Skip to content

Instantly share code, notes, and snippets.

@libetl
Last active December 30, 2023 20:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save libetl/d5ace216bc5ee6e7cad84c4cefd4f98d to your computer and use it in GitHub Desktop.
Save libetl/d5ace216bc5ee6e7cad84c4cefd4f98d to your computer and use it in GitHub Desktop.
How Client-Side Encryption works
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
}
}
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()))
}
}
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
})
})
}
#!/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\"}"
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)
}
}
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
)
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
)
}
}
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