Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion runtime/runtime-core/api/runtime-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,16 @@ public final class aws/smithy/kotlin/runtime/hashing/Crc32cKt {
}

public final class aws/smithy/kotlin/runtime/hashing/EcdsaJVMKt {
public static final fun ecdsaSecp256r1 ([B[B)[B
Copy link
Contributor Author

@xinsong-cui xinsong-cui Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing ecdsaSecp256r1(key, message) calls work unchanged, should not be a breaking change

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely a breaking change because you've altered the method signature. Older consumers of runtime-core will encounter a NoSuchMethodError exception at runtime. The only way to do this safely without breaking binary compatibility is to maintain the old method signature and add a new one.

I recommend one of the following approaches:

  • Expose a new method ecdsaSecp256r1Rs which invokes ecdsaSecp256r1Rs and extracts the r and s values. This could be implemented in common since it's just byte manipulation
  • Expose a new method asn1DerToRs which converts any ByteArray from ASN.1 DER format to r||s. Callers would then invoke ecdsaSecp256r1Rs first and then asn1DerToRs. This could also be implemented in common.

public static final fun ecdsaSecp256r1 ([B[BLaws/smithy/kotlin/runtime/hashing/EcdsaSignatureType;)[B
public static synthetic fun ecdsaSecp256r1$default ([B[BLaws/smithy/kotlin/runtime/hashing/EcdsaSignatureType;ILjava/lang/Object;)[B
}

public final class aws/smithy/kotlin/runtime/hashing/EcdsaSignatureType : java/lang/Enum {
public static final field ASN1_DER Laws/smithy/kotlin/runtime/hashing/EcdsaSignatureType;
public static final field RAW_RS Laws/smithy/kotlin/runtime/hashing/EcdsaSignatureType;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Laws/smithy/kotlin/runtime/hashing/EcdsaSignatureType;
public static fun values ()[Laws/smithy/kotlin/runtime/hashing/EcdsaSignatureType;
}

public abstract interface class aws/smithy/kotlin/runtime/hashing/HashFunction {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
*/
package aws.smithy.kotlin.runtime.hashing

public enum class EcdsaSignatureType {
ASN1_DER,
RAW_RS,
}

/**
* ECDSA on the SECP256R1 curve.
*/
public expect fun ecdsaSecp256r1(key: ByteArray, message: ByteArray): ByteArray
public expect fun ecdsaSecp256r1(
key: ByteArray,
message: ByteArray,
signatureType: EcdsaSignatureType = EcdsaSignatureType.ASN1_DER,
): ByteArray
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import java.security.spec.*
/**
* ECDSA on the SECP256R1 curve.
*/
public actual fun ecdsaSecp256r1(key: ByteArray, message: ByteArray): ByteArray {
public actual fun ecdsaSecp256r1(
key: ByteArray,
message: ByteArray,
signatureType: EcdsaSignatureType,
): ByteArray {
// Convert private key to BigInteger
val d = BigInteger(key)

Expand All @@ -28,10 +32,42 @@ public actual fun ecdsaSecp256r1(key: ByteArray, message: ByteArray): ByteArray
val privateKey = keyFactory.generatePrivate(privateKeySpec)

// Sign the message
return Signature.getInstance("SHA256withECDSA").apply {
val derSignature = Signature.getInstance("SHA256withECDSA").apply {
initSign(privateKey)
update(message)
}.sign()

return when (signatureType) {
EcdsaSignatureType.ASN1_DER -> derSignature
EcdsaSignatureType.RAW_RS -> parseDerSignature(derSignature)
}
}

private fun BigInteger.toJvm(): java.math.BigInteger = java.math.BigInteger(1, toByteArray())

/**
* Parses an ASN.1 DER encoded ECDSA signature and converts it to raw r||s format.
*/
private fun parseDerSignature(derSignature: ByteArray): ByteArray {
var index = 2 // Skip SEQUENCE tag and length

// Read r
index++ // Skip INTEGER tag
val rLength = derSignature[index++].toInt() and 0xFF
val r = derSignature.sliceArray(index until index + rLength)
index += rLength

// Read s
index++ // Skip INTEGER tag
val sLength = derSignature[index++].toInt() and 0xFF
val s = derSignature.sliceArray(index until index + sLength)

// Remove leading zero bytes and pad to 32 bytes
val rFixed = r.dropWhile { it == 0.toByte() }.toByteArray()
val sFixed = s.dropWhile { it == 0.toByte() }.toByteArray()

val rPadded = if (rFixed.size < 32) ByteArray(32 - rFixed.size) + rFixed else rFixed
val sPadded = if (sFixed.size < 32) ByteArray(32 - sFixed.size) + sFixed else sFixed

return rPadded + sPadded
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Where did we find this algorithm? Can we link to a reliable source?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really find any available algorithm, I find this is helpful: the structure of DER (which tells where is r and s located in DER)

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
package aws.smithy.kotlin.runtime.hashing

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import java.security.*
Expand All @@ -30,6 +31,11 @@ class EcdsaJVMTest {

assertTrue(signature.isNotEmpty())
assertTrue(signature.size >= 64) // ECDSA signatures are typically 70-72 bytes in DER format

val rawSignature = ecdsaSecp256r1(privateKey, message, EcdsaSignatureType.RAW_RS)

assertTrue(rawSignature.isNotEmpty())
assertEquals(64, rawSignature.size) // Raw signature should be exactly 64 bytes (32 bytes r + 32 bytes s)
}

@Test
Expand All @@ -44,6 +50,13 @@ class EcdsaJVMTest {
assertTrue(signature1.isNotEmpty())
assertTrue(signature2.isNotEmpty())
assertFalse(signature1.contentEquals(signature2))

val rawSignature1 = ecdsaSecp256r1(privateKey, message1, EcdsaSignatureType.RAW_RS)
val rawSignature2 = ecdsaSecp256r1(privateKey, message2, EcdsaSignatureType.RAW_RS)

assertTrue(rawSignature1.isNotEmpty())
assertTrue(rawSignature2.isNotEmpty())
assertFalse(rawSignature1.contentEquals(rawSignature2))
}

@Test
Expand All @@ -53,6 +66,9 @@ class EcdsaJVMTest {

val signature = ecdsaSecp256r1(privateKey, message)
assertTrue(signature.isNotEmpty())

val rawSignature = ecdsaSecp256r1(privateKey, message, EcdsaSignatureType.RAW_RS)
assertTrue(rawSignature.isNotEmpty())
}

@Test
Expand All @@ -62,6 +78,9 @@ class EcdsaJVMTest {

val signature = ecdsaSecp256r1(privateKey, largeMessage)
assertTrue(signature.isNotEmpty())

val rawSignature = ecdsaSecp256r1(privateKey, largeMessage, EcdsaSignatureType.RAW_RS)
assertTrue(rawSignature.isNotEmpty())
}

@Test
Expand Down
Loading