Skip to content

Commit

Permalink
Implement account request message serialisation
Browse files Browse the repository at this point in the history
  • Loading branch information
gnarea committed Sep 15, 2023
1 parent 36e0d20 commit 5b974be
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 1 deletion.
4 changes: 3 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ dependencies {
implementation 'com.github.relaycorp:awala-endpoint-android:1.13.22'

// Letro messaging
implementation("org.bouncycastle:bcprov-jdk15on:1.70")
def bouncy_castle_version = "1.70"
implementation("org.bouncycastle:bcprov-jdk15on:$bouncy_castle_version")
implementation("org.bouncycastle:bcpkix-jdk15on:$bouncy_castle_version")

// Compose
implementation platform('androidx.compose:compose-bom:2023.06.01')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package tech.relaycorp.letro.server.messages

import org.bouncycastle.asn1.DERBitString
import org.bouncycastle.asn1.DERUTF8String
import org.bouncycastle.asn1.DERVisibleString
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import tech.relaycorp.letro.utils.asn1.ASN1Utils
import tech.relaycorp.letro.utils.crypto.RSASigning
import java.security.PrivateKey
import java.security.PublicKey
import java.util.Locale

class AccountRequest(
val userName: String,
val locale: Locale,
val veraidMemberPublicKey: PublicKey,
) {
fun serialise(veraidMemberPrivateKey: PrivateKey): ByteArray {
val requestEncoded = ASN1Utils.makeSequence(
listOf(
DERUTF8String(userName),
serialiseLocale(),
SubjectPublicKeyInfo.getInstance(veraidMemberPublicKey.encoded),
),
explicitTagging = false,
)
val signature = RSASigning.sign(requestEncoded.encoded, veraidMemberPrivateKey)
return ASN1Utils.serializeSequence(listOf(requestEncoded, DERBitString(signature)), false)
}

private fun serialiseLocale(): DERVisibleString {
val languageCode = locale.language.lowercase()
val countryCode = locale.country.lowercase()
val localeNormalised = if (languageCode.isEmpty()) {
""
} else if (countryCode.isEmpty()) {
languageCode
} else {
"$languageCode-$countryCode"
}
return DERVisibleString(localeNormalised)
}
}
30 changes: 30 additions & 0 deletions app/src/main/java/tech/relaycorp/letro/utils/crypto/RSASigning.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package tech.relaycorp.letro.utils.crypto

import java.security.PrivateKey
import java.security.PublicKey
import java.security.Signature

/**
* Plain RSA signatures (without PKCS#7/CMS SignedData).
*/
internal object RSASigning {
fun sign(plaintext: ByteArray, privateKey: PrivateKey): ByteArray {
val signature = makeSignature()
signature.initSign(privateKey)
signature.update(plaintext)
return signature.sign()
}

fun verify(
signatureSerialisation: ByteArray,
publicKey: PublicKey,
expectedPlaintext: ByteArray,
): Boolean {
val signature = makeSignature()
signature.initVerify(publicKey)
signature.update(expectedPlaintext)
return signature.verify(signatureSerialisation)
}

private fun makeSignature() = Signature.getInstance("SHA256withRSAandMGF1", BC_PROVIDER)
}
7 changes: 7 additions & 0 deletions app/src/main/java/tech/relaycorp/letro/utils/crypto/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@file:JvmName("Utils")

package tech.relaycorp.letro.utils.crypto

import org.bouncycastle.jce.provider.BouncyCastleProvider

internal val BC_PROVIDER = BouncyCastleProvider()
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package tech.relaycorp.letro.server.messages

import io.kotest.matchers.should
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.beUpperCase
import io.kotest.matchers.string.match
import org.bouncycastle.asn1.ASN1BitString
import org.bouncycastle.asn1.ASN1Sequence
import org.bouncycastle.asn1.ASN1TaggedObject
import org.bouncycastle.asn1.ASN1UTF8String
import org.bouncycastle.asn1.ASN1VisibleString
import org.bouncycastle.asn1.DERSequence
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair
import tech.relaycorp.letro.utils.asn1.ASN1Utils
import tech.relaycorp.letro.utils.crypto.RSASigning
import java.util.Locale

const val USER_NAME = "alice"
val LOCALE = Locale("EN", "GB")

val keyPair = generateRSAKeyPair()

class AccountRequestTest {
@Nested
inner class Serialize {
@Nested
inner class RequestSerialisation {
@Nested
inner class UserName {
@Test
fun `Should serialise the user name as UTF8String`() {
val request = AccountRequest(
userName = USER_NAME,
locale = LOCALE,
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val requestSequence = parseRequestSequence(serialisation)
val userNameEncoded = ASN1UTF8String.getInstance(
requestSequence.getObjectAt(0) as ASN1TaggedObject,
false,
)
userNameEncoded.string shouldBe USER_NAME
}

@Test
fun `Should support non-ASCII characters`() {
val userName = "👩‍💻"
val request = AccountRequest(
userName = userName,
locale = LOCALE,
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val requestSequence = parseRequestSequence(serialisation)
val userNameEncoded = ASN1UTF8String.getInstance(
requestSequence.getObjectAt(0) as ASN1TaggedObject,
false,
)
userNameEncoded.string shouldBe userName
}
}

@Nested
inner class LocaleSerialisation {
@Test
fun `Should serialise the language as a string`() {
val request = AccountRequest(
userName = USER_NAME,
locale = LOCALE,
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val locale = parseLocaleSequence(serialisation)
locale.string.split("-")[0] shouldBe LOCALE.language.lowercase()
}

@Test
fun `Should serialise the country as a lowercase string`() {
val request = AccountRequest(
userName = USER_NAME,
locale = LOCALE,
veraidMemberPublicKey = keyPair.public,
)
request.locale.country should beUpperCase()

val serialisation = request.serialise(keyPair.private)

val locale = parseLocaleSequence(serialisation)
val countryCode = locale.string.split("-")[1]
countryCode shouldBe LOCALE.country.lowercase()
}

@Test
fun `Should result in empty string if language is missing`() {
val request = AccountRequest(
userName = USER_NAME,
locale = Locale("", LOCALE.country),
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val locale = parseLocaleSequence(serialisation)
locale.string shouldBe ""
}

@Test
fun `Should only serialise the language if country is missing`() {
val request = AccountRequest(
userName = USER_NAME,
locale = Locale(LOCALE.language, ""),
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val locale = parseLocaleSequence(serialisation)
locale.string shouldBe LOCALE.language
}

@Test
fun `Should not serialise the variant`() {
val request = AccountRequest(
userName = USER_NAME,
locale = Locale(LOCALE.language, LOCALE.country, "Oxford"),
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val locale = parseLocaleSequence(serialisation)
locale.string should match(Regex("[a-z]{2}-[a-z]{2}"))
}

private fun parseLocaleSequence(serialisation: ByteArray): ASN1VisibleString {
val requestSequence = parseRequestSequence(serialisation)
return ASN1Utils.getVisibleString(requestSequence.getObjectAt(1) as ASN1TaggedObject)
}
}

@Test
fun `Should serialize the public key`() {
val request = AccountRequest(
userName = USER_NAME,
locale = LOCALE,
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val requestSequence = parseRequestSequence(serialisation)
val publicKeyEncoded = SubjectPublicKeyInfo.getInstance(
requestSequence.getObjectAt(2) as ASN1TaggedObject,
false,
)
publicKeyEncoded.encoded shouldBe keyPair.public.encoded
}

private fun parseRequestSequence(serialisation: ByteArray): ASN1Sequence {
val signatureSequence = ASN1Utils.deserializeHeterogeneousSequence(serialisation)
return DERSequence.getInstance(signatureSequence[0], false)
}
}

@Nested
inner class Signature {
@Test
fun `Signature should be serialised as a BIT STRING`() {
val request = AccountRequest(
userName = USER_NAME,
locale = LOCALE,
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val signatureSequence = ASN1Utils.deserializeHeterogeneousSequence(serialisation)
ASN1BitString.getInstance(signatureSequence[1], false) // Should not throw
}

@Test
fun `Signature should be computed over the request`() {
val request = AccountRequest(
userName = USER_NAME,
locale = LOCALE,
veraidMemberPublicKey = keyPair.public,
)

val serialisation = request.serialise(keyPair.private)

val signatureSequence = ASN1Utils.deserializeHeterogeneousSequence(serialisation)
val requestSequence = DERSequence.getInstance(signatureSequence[0], false)
val signatureEncoded = ASN1BitString.getInstance(signatureSequence[1], false)
RSASigning.verify(
signatureEncoded.bytes,
keyPair.public,
requestSequence.encoded,
) shouldBe true
}
}
}
}
10 changes: 10 additions & 0 deletions app/src/test/java/tech/relaycorp/letro/testing/crypto/Keys.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package tech.relaycorp.letro.testing.crypto

import java.security.KeyPair
import java.security.KeyPairGenerator

fun generateRSAKeyPair(): KeyPair {
val keyGen = KeyPairGenerator.getInstance("RSA")
keyGen.initialize(2048)
return keyGen.generateKeyPair()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package tech.relaycorp.letro.utils.crypto

import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair
import java.security.Signature
import java.security.spec.MGF1ParameterSpec
import java.security.spec.PSSParameterSpec

class RSASigningTest {
private val plaintext = "the plaintext".toByteArray()
private val keyPair = generateRSAKeyPair()

@Nested
inner class Sign {
@Test
fun `The plaintext should be signed with RSA-PSS, SHA-256 and MGF1`() {
val ciphertext = RSASigning.sign(plaintext, keyPair.private)

val signature: Signature = makeSignature()
signature.initVerify(keyPair.public)
signature.update(plaintext)

signature.verify(ciphertext) shouldBe true
}
}

@Nested
inner class Verify {
@Test
fun `Invalid plaintexts should be refused`() {
val anotherPlaintext = byteArrayOf(*plaintext, 1)
val ciphertext = RSASigning.sign(anotherPlaintext, keyPair.private)

RSASigning.verify(ciphertext, keyPair.public, plaintext) shouldBe false
}

@Test
fun `Algorithms other than RSA-PSS with SHA-256 and MGF1 should be refused`() {
val signature: Signature = Signature.getInstance("SHA256withRSA", BC_PROVIDER)
signature.initSign(keyPair.private)
signature.update(plaintext)
val ciphertext = signature.sign()

RSASigning.verify(ciphertext, keyPair.public, plaintext) shouldBe false
}

@Test
fun `Valid signatures should be accepted`() {
val ciphertext = RSASigning.sign(plaintext, keyPair.private)

RSASigning.verify(ciphertext, keyPair.public, plaintext) shouldBe true
}
}

private fun makeSignature(): Signature {
val signature = Signature.getInstance("SHA256withRSA/PSS", BC_PROVIDER)
val pssParameterSpec = PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)
signature.setParameter(pssParameterSpec)
return signature
}
}

0 comments on commit 5b974be

Please sign in to comment.