From ef9f487dc008b536207ac779841ba621b3ee03f2 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Wed, 25 Oct 2023 19:00:10 +0100 Subject: [PATCH] feat: Allow `SignatureBundle`s to encapsulate plaintext (#67) LTR-180 --- src/integrationTest/kotlin/MainTest.kt | 6 +- .../tech/relaycorp/veraid/SignatureBundle.kt | 25 +++-- .../veraid/SignatureBundleVerification.kt | 32 ++++++ .../relaycorp/veraid/SignatureBundleTest.kt | 99 ++++++++++++++++++- .../veraid/SignatureBundleVerificationTest.kt | 53 ++++++++++ 5 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/tech/relaycorp/veraid/SignatureBundleVerification.kt create mode 100644 src/test/kotlin/tech/relaycorp/veraid/SignatureBundleVerificationTest.kt diff --git a/src/integrationTest/kotlin/MainTest.kt b/src/integrationTest/kotlin/MainTest.kt index 1d56896..12be674 100644 --- a/src/integrationTest/kotlin/MainTest.kt +++ b/src/integrationTest/kotlin/MainTest.kt @@ -49,8 +49,12 @@ class MainTest { now, ) - val member = signatureBundle.verify(TestStubs.PLAINTEXT, TestStubs.TEST_SERVICE_OID) + val (plaintext, member) = signatureBundle.verify( + TestStubs.PLAINTEXT, + TestStubs.TEST_SERVICE_OID, + ) + assert(plaintext.contentEquals(TestStubs.PLAINTEXT)) assert(member.orgName == TestStubs.ORG_NAME) assert(member.userName == TestStubs.USER_NAME) } diff --git a/src/main/kotlin/tech/relaycorp/veraid/SignatureBundle.kt b/src/main/kotlin/tech/relaycorp/veraid/SignatureBundle.kt index 9cb70f5..8cef8be 100644 --- a/src/main/kotlin/tech/relaycorp/veraid/SignatureBundle.kt +++ b/src/main/kotlin/tech/relaycorp/veraid/SignatureBundle.kt @@ -44,29 +44,30 @@ public class SignatureBundle internal constructor( * @param plaintext The plaintext whose signature is to be verified. * @param serviceOid The OID of the service to which the signature is bound. * @param date The date against which to verify the signature. - * @return The member that signed the signature, if verification succeeds. + * @return The member that produced the signature and the respective plaintext. * @throws SignatureException If the bundle is invalid. */ public suspend fun verify( - plaintext: ByteArray, + plaintext: ByteArray?, serviceOid: String, date: ZonedDateTime, - ): Member = verify(plaintext, serviceOid, date..date) + ): SignatureBundleVerification = verify(plaintext, serviceOid, date..date) /** * Verify the bundle. * - * @param plaintext The plaintext whose signature is to be verified. + * @param plaintext The plaintext whose signature is to be verified (if the bundle isn't + * expected to encapsulate it). * @param serviceOid The OID of the service to which the signature is bound. * @param datePeriod The period against which to verify the signature. - * @return The member that signed the signature, if verification succeeds. + * @return The member that produced the signature and the respective plaintext. * @throws SignatureException If the bundle is invalid. */ public suspend fun verify( - plaintext: ByteArray, + plaintext: ByteArray?, serviceOid: String, datePeriod: DatePeriod? = null, - ): Member { + ): SignatureBundleVerification { val now = ZonedDateTime.now() val verificationPeriod = datePeriod ?: now..now if (verificationPeriod.endInclusive < verificationPeriod.start) { @@ -90,11 +91,16 @@ public class SignatureBundle internal constructor( ) } - return try { + val member = try { memberIdBundle.verify(serviceOid, signaturePeriodIntersection) } catch (exc: PkiException) { throw SignatureException("Member id bundle is invalid", exc) } + + return SignatureBundleVerification( + signedData.plaintext ?: plaintext!!, + member, + ) } private fun getSignatureMetadata(): SignatureMetadata { @@ -133,6 +139,7 @@ public class SignatureBundle internal constructor( signingKey: PrivateKey, expiryDate: ZonedDateTime, startDate: ZonedDateTime = ZonedDateTime.now(), + encapsulatePlaintext: Boolean = false, ): SignatureBundle { val metadata = SignatureMetadata( ASN1ObjectIdentifier(serviceOid), @@ -147,7 +154,7 @@ public class SignatureBundle internal constructor( signingKey, memberIdBundle.memberCertificate, setOf(memberIdBundle.memberCertificate, memberIdBundle.orgCertificate), - encapsulatePlaintext = false, + encapsulatePlaintext = encapsulatePlaintext, extraSignedAttrs = listOf(metadataAttribute), ) return SignatureBundle(memberIdBundle, signedData) diff --git a/src/main/kotlin/tech/relaycorp/veraid/SignatureBundleVerification.kt b/src/main/kotlin/tech/relaycorp/veraid/SignatureBundleVerification.kt new file mode 100644 index 0000000..d7cd32b --- /dev/null +++ b/src/main/kotlin/tech/relaycorp/veraid/SignatureBundleVerification.kt @@ -0,0 +1,32 @@ +package tech.relaycorp.veraid + +/** + * Successful signature bundle verification. + */ +public data class SignatureBundleVerification( + /** + * The plaintext whose signature was verified. + */ + public val plaintext: ByteArray, + + /** + * The member that produced the signature. + */ + public val member: Member, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SignatureBundleVerification + + if (!plaintext.contentEquals(other.plaintext)) return false + return member == other.member + } + + override fun hashCode(): Int { + var result = plaintext.contentHashCode() + result = 31 * result + member.hashCode() + return result + } +} diff --git a/src/test/kotlin/tech/relaycorp/veraid/SignatureBundleTest.kt b/src/test/kotlin/tech/relaycorp/veraid/SignatureBundleTest.kt index 99dfe81..3661aaf 100644 --- a/src/test/kotlin/tech/relaycorp/veraid/SignatureBundleTest.kt +++ b/src/test/kotlin/tech/relaycorp/veraid/SignatureBundleTest.kt @@ -125,6 +125,20 @@ class SignatureBundleTest { signatureBundle.signedData.plaintext shouldBe null } + + @Test + fun `Plaintext should be attached if requested`() { + val signatureBundle = SignatureBundle.generate( + plaintext, + SERVICE_OID.id, + memberIdBundle, + MEMBER_KEY_PAIR.private, + signatureExpiry, + encapsulatePlaintext = true, + ) + + signatureBundle.signedData.plaintext shouldBe plaintext + } } @Nested @@ -683,15 +697,94 @@ class SignatureBundleTest { } } + @Nested + inner class Plaintext { + @Test + fun `Verification should fail if plaintext is attached and passed`() = runTest { + val bundle = SignatureBundle.generate( + plaintext, + SERVICE_OID.id, + memberIdBundle, + MEMBER_KEY_PAIR.private, + validityPeriod.endInclusive, + validityPeriod.start, + encapsulatePlaintext = true, + ) + + val exception = assertThrows { + bundle.verify(plaintext, SERVICE_OID.id) + } + + exception.cause shouldBe instanceOf() + } + + @Test + fun `Verification should fail if plaintext is detached and not passed`() = runTest { + val bundle = SignatureBundle.generate( + plaintext, + SERVICE_OID.id, + memberIdBundle, + MEMBER_KEY_PAIR.private, + validityPeriod.endInclusive, + validityPeriod.start, + encapsulatePlaintext = false, + ) + + val exception = assertThrows { + bundle.verify(null, SERVICE_OID.id) + } + + exception.cause shouldBe instanceOf() + } + } + @Nested inner class ValidResult { + @Test + fun `Plaintext should be taken from bundle if attached`() = runTest { + val attachedPlaintext = "attached".toByteArray() + val signedData = SignatureBundle.generate( + attachedPlaintext, + SERVICE_OID.id, + memberIdBundle, + MEMBER_KEY_PAIR.private, + validityPeriod.endInclusive, + validityPeriod.start, + encapsulatePlaintext = true, + ).signedData + val bundle = SignatureBundle(mockMemberIdBundle(), signedData) + + val result = bundle.verify(null, SERVICE_OID.id) + + result.plaintext shouldBe attachedPlaintext + } + + @Test + fun `Plaintext should be taken from parameter if detached`() = runTest { + val detachedPlaintext = "deattached".toByteArray() + val signedData = SignatureBundle.generate( + detachedPlaintext, + SERVICE_OID.id, + memberIdBundle, + MEMBER_KEY_PAIR.private, + validityPeriod.endInclusive, + validityPeriod.start, + encapsulatePlaintext = false, + ).signedData + val bundle = SignatureBundle(mockMemberIdBundle(), signedData) + + val result = bundle.verify(detachedPlaintext, SERVICE_OID.id) + + result.plaintext shouldBe detachedPlaintext + } + @Test fun `Organisation name should be output`() = runTest { val bundle = SignatureBundle(mockMemberIdBundle(), validBundle.signedData) val result = bundle.verify(plaintext, SERVICE_OID.id) - result.orgName shouldBe ORG_NAME + result.member.orgName shouldBe ORG_NAME } @Test @@ -700,7 +793,7 @@ class SignatureBundleTest { val result = bundle.verify(plaintext, SERVICE_OID.id) - result.userName shouldBe USER_NAME + result.member.userName shouldBe USER_NAME } @Test @@ -710,7 +803,7 @@ class SignatureBundleTest { val result = bundle.verify(plaintext, SERVICE_OID.id) - result.userName shouldBe null + result.member.userName shouldBe null } } diff --git a/src/test/kotlin/tech/relaycorp/veraid/SignatureBundleVerificationTest.kt b/src/test/kotlin/tech/relaycorp/veraid/SignatureBundleVerificationTest.kt new file mode 100644 index 0000000..8c53c06 --- /dev/null +++ b/src/test/kotlin/tech/relaycorp/veraid/SignatureBundleVerificationTest.kt @@ -0,0 +1,53 @@ +package tech.relaycorp.veraid + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class SignatureBundleVerificationTest { + val verification = SignatureBundleVerification( + "plaintext".toByteArray(), + Member(ORG_NAME, USER_NAME), + ) + + @Nested + inner class Equals { + @Test + @Suppress("ReplaceCallWithBinaryOperator") + fun `The same object should equal`() { + verification.equals(verification) shouldBe true + } + + @Test + fun `Null should not equal`() { + verification.equals(null) shouldBe false + } + + @Test + fun `Different class should not equal`() { + verification.equals("foo") shouldBe false + } + + @Test + fun `Different plaintext should not equal`() { + verification shouldNotBe verification.copy(plaintext = "foo".toByteArray()) + } + + @Test + fun `Different member should not equal`() { + val differentMember = verification.member.copy(orgName = "not-$ORG_NAME") + + verification shouldNotBe verification.copy(member = differentMember) + } + } + + @Test + fun `Hashcode should compine member and plaintext`() { + val constant = 31 + val expectedHashCode = + verification.member.hashCode() + constant * verification.plaintext.contentHashCode() + + verification.hashCode() shouldBe expectedHashCode + } +}