From e1d79f1760051a188cea3d4078362275ea0d4b0d Mon Sep 17 00:00:00 2001 From: Aria Wisp Date: Sun, 7 Sep 2025 11:35:03 -0600 Subject: [PATCH 1/5] asn1(core): RFC 8410 OIDs + ABSENT params; preserve unknown AlgorithmIdentifier params via Asn1Any; add SEQUENCE OF support; update API/ABI and changelog - Add ObjectIdentifier extensions for Ed25519/Ed448/X25519/X448 - Enforce RFC 8410 on encode (omit parameters); decoder tolerates NULL - Introduce Asn1Any and carry parameters in UnknownKeyAlgorithmIdentifier - Support StructureKind.LIST (SEQUENCE OF) in DER codec - Update API dumps and CHANGELOG; add PR description --- CHANGELOG.md | 9 +++ .../api/cryptography-serialization-asn1.api | 19 ++++++ .../cryptography-serialization-asn1.klib.api | 20 +++++++ ...ryptography-serialization-asn1-modules.api | 3 +- ...graphy-serialization-asn1-modules.klib.api | 4 +- .../kotlin/KeyAlgorithmIdentifier.kt | 8 +-- .../KeyAlgorithmIdentifierSerializer.kt | 42 ++++++++++--- .../modules/src/commonMain/kotlin/Oids.kt | 19 ++++++ .../asn1/src/commonMain/kotlin/Any.kt | 15 +++++ .../commonMain/kotlin/internal/DerDecoder.kt | 60 ++++++++++--------- .../commonMain/kotlin/internal/DerEncoder.kt | 5 +- .../commonMain/kotlin/internal/DerInput.kt | 28 +++++++++ .../commonMain/kotlin/internal/DerOutput.kt | 4 ++ 13 files changed, 190 insertions(+), 46 deletions(-) create mode 100644 cryptography-serialization/asn1/modules/src/commonMain/kotlin/Oids.kt create mode 100644 cryptography-serialization/asn1/src/commonMain/kotlin/Any.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fab6a50..43cadae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG +## Unreleased + +### ASN.1/DER + +- RFC 8410 compliance: Ed25519/Ed448/X25519/X448 AlgorithmIdentifier encodes with ABSENT parameters; decoder tolerates explicit NULL. +- Unknown AlgorithmIdentifier parameters are preserved as raw ASN.1 for round-trip via new `Asn1Any` type. +- Support SEQUENCE OF (list) encode/decode in DER codec. + + ## 0.5.0 – CryptoKit & optimal providers > Published 30 Jun 2025 diff --git a/cryptography-serialization/asn1/api/cryptography-serialization-asn1.api b/cryptography-serialization/asn1/api/cryptography-serialization-asn1.api index 90e48113..659b5c71 100644 --- a/cryptography-serialization/asn1/api/cryptography-serialization-asn1.api +++ b/cryptography-serialization/asn1/api/cryptography-serialization-asn1.api @@ -90,3 +90,22 @@ public final class dev/whyoleg/cryptography/serialization/asn1/ObjectIdentifier$ public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class dev/whyoleg/cryptography/serialization/asn1/Asn1Any { + public static final field Companion Ldev/whyoleg/cryptography/serialization/asn1/Asn1Any$Companion; + public fun ([B)V + public final fun getBytes ()[B +} + +public final synthetic class dev/whyoleg/cryptography/serialization/asn1/Asn1Any$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Ldev/whyoleg/cryptography/serialization/asn1/Asn1Any$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/whyoleg/cryptography/serialization/asn1/Asn1Any; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/whyoleg/cryptography/serialization/asn1/Asn1Any;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V +} + +public final class dev/whyoleg/cryptography/serialization/asn1/Asn1Any$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} diff --git a/cryptography-serialization/asn1/api/cryptography-serialization-asn1.klib.api b/cryptography-serialization/asn1/api/cryptography-serialization-asn1.klib.api index bd049688..db1b6516 100644 --- a/cryptography-serialization/asn1/api/cryptography-serialization-asn1.klib.api +++ b/cryptography-serialization/asn1/api/cryptography-serialization-asn1.klib.api @@ -48,6 +48,26 @@ final class dev.whyoleg.cryptography.serialization.asn1/BitArray { // dev.whyole } } +final class dev.whyoleg.cryptography.serialization.asn1/Asn1Any { // dev.whyoleg.cryptography.serialization.asn1/Asn1Any|null[0] + constructor (kotlin/ByteArray) // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.|(kotlin.ByteArray){}[0] + + final val bytes // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.bytes|{}bytes[0] + final fun (): kotlin/ByteArray // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.bytes.|(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.$serializer|null[0] + final val descriptor // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): dev.whyoleg.cryptography.serialization.asn1/Asn1Any // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, dev.whyoleg.cryptography.serialization.asn1/Asn1Any) // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;dev.whyoleg.cryptography.serialization.asn1.Asn1Any){}[0] + } + + final object Companion { // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.Companion|null[0] + final fun serializer(): kotlinx.serialization/KSerializer // dev.whyoleg.cryptography.serialization.asn1/Asn1Any.Companion.serializer|serializer(){}[0] + } + + final value class dev.whyoleg.cryptography.serialization.asn1/ObjectIdentifier { // dev.whyoleg.cryptography.serialization.asn1/ObjectIdentifier|null[0] constructor (kotlin/String) // dev.whyoleg.cryptography.serialization.asn1/ObjectIdentifier.|(kotlin.String){}[0] diff --git a/cryptography-serialization/asn1/modules/api/cryptography-serialization-asn1-modules.api b/cryptography-serialization/asn1/modules/api/cryptography-serialization-asn1-modules.api index c149e8a5..7feb3696 100644 --- a/cryptography-serialization/asn1/modules/api/cryptography-serialization-asn1-modules.api +++ b/cryptography-serialization/asn1/modules/api/cryptography-serialization-asn1-modules.api @@ -248,6 +248,5 @@ public final class dev/whyoleg/cryptography/serialization/asn1/modules/UnknownKe public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getAlgorithm-STa95mE ()Ljava/lang/String; public synthetic fun getParameters ()Ljava/lang/Object; - public fun getParameters ()Ljava/lang/Void; + public fun getParameters ()Ljava/lang/Object; } - diff --git a/cryptography-serialization/asn1/modules/api/cryptography-serialization-asn1-modules.klib.api b/cryptography-serialization/asn1/modules/api/cryptography-serialization-asn1-modules.klib.api index cb15bdd5..24162c4f 100644 --- a/cryptography-serialization/asn1/modules/api/cryptography-serialization-asn1-modules.klib.api +++ b/cryptography-serialization/asn1/modules/api/cryptography-serialization-asn1-modules.klib.api @@ -200,12 +200,12 @@ final class dev.whyoleg.cryptography.serialization.asn1.modules/SubjectPublicKey } final class dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier : dev.whyoleg.cryptography.serialization.asn1.modules/KeyAlgorithmIdentifier { // dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier|null[0] - constructor (dev.whyoleg.cryptography.serialization.asn1/ObjectIdentifier) // dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier.|(dev.whyoleg.cryptography.serialization.asn1.ObjectIdentifier){}[0] + constructor (dev.whyoleg.cryptography.serialization.asn1/ObjectIdentifier, kotlin/Any?) // dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier.|(dev.whyoleg.cryptography.serialization.asn1.ObjectIdentifier;kotlin.Any?){}[0] final val algorithm // dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier.algorithm|{}algorithm[0] final fun (): dev.whyoleg.cryptography.serialization.asn1/ObjectIdentifier // dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier.algorithm.|(){}[0] final val parameters // dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier.parameters|{}parameters[0] - final fun (): kotlin/Nothing? // dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier.parameters.|(){}[0] + final fun (): kotlin/Any? // dev.whyoleg.cryptography.serialization.asn1.modules/UnknownKeyAlgorithmIdentifier.parameters.|(){}[0] } final value class dev.whyoleg.cryptography.serialization.asn1.modules/EcParameters { // dev.whyoleg.cryptography.serialization.asn1.modules/EcParameters|null[0] diff --git a/cryptography-serialization/asn1/modules/src/commonMain/kotlin/KeyAlgorithmIdentifier.kt b/cryptography-serialization/asn1/modules/src/commonMain/kotlin/KeyAlgorithmIdentifier.kt index 69b0f70b..232eb54d 100644 --- a/cryptography-serialization/asn1/modules/src/commonMain/kotlin/KeyAlgorithmIdentifier.kt +++ b/cryptography-serialization/asn1/modules/src/commonMain/kotlin/KeyAlgorithmIdentifier.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.* @Serializable(KeyAlgorithmIdentifierSerializer::class) public interface KeyAlgorithmIdentifier : AlgorithmIdentifier -public class UnknownKeyAlgorithmIdentifier(override val algorithm: ObjectIdentifier) : KeyAlgorithmIdentifier { - override val parameters: Nothing? get() = null -} - +public class UnknownKeyAlgorithmIdentifier( + override val algorithm: ObjectIdentifier, + override val parameters: Any? = null, +) : KeyAlgorithmIdentifier diff --git a/cryptography-serialization/asn1/modules/src/commonMain/kotlin/KeyAlgorithmIdentifierSerializer.kt b/cryptography-serialization/asn1/modules/src/commonMain/kotlin/KeyAlgorithmIdentifierSerializer.kt index 4f2ad883..b4a280d4 100644 --- a/cryptography-serialization/asn1/modules/src/commonMain/kotlin/KeyAlgorithmIdentifierSerializer.kt +++ b/cryptography-serialization/asn1/modules/src/commonMain/kotlin/KeyAlgorithmIdentifierSerializer.kt @@ -11,11 +11,31 @@ import kotlinx.serialization.encoding.* @OptIn(ExperimentalSerializationApi::class) internal object KeyAlgorithmIdentifierSerializer : AlgorithmIdentifierSerializer() { - override fun CompositeEncoder.encodeParameters(value: KeyAlgorithmIdentifier): Unit = when (value) { - is RsaKeyAlgorithmIdentifier -> encodeParameters(NothingSerializer(), RsaKeyAlgorithmIdentifier.parameters) - is EcKeyAlgorithmIdentifier -> encodeParameters(EcParameters.serializer(), value.parameters) - is UnknownKeyAlgorithmIdentifier -> encodeParameters(NothingSerializer(), value.parameters) - else -> encodeParameters(NothingSerializer(), null) + override fun CompositeEncoder.encodeParameters(value: KeyAlgorithmIdentifier) { + when (value) { + is RsaKeyAlgorithmIdentifier -> encodeParameters(NothingSerializer(), null) // explicit NULL per RSA + is EcKeyAlgorithmIdentifier -> encodeParameters(EcParameters.serializer(), value.parameters) + is UnknownKeyAlgorithmIdentifier -> { + // RFC 8410: parameters MUST be ABSENT for Ed25519/Ed448/X25519/X448 + if (value.algorithm.isRfc8410NoParams()) return + when (val p = value.parameters) { + null -> { + // For unknown algorithms, prefer ABSENT when no parameters provided + // (do nothing). If explicit NULL must be preserved, p will be Asn1Any(05 00). + return + } + is Asn1Any -> encodeParameters(Asn1Any.serializer(), p) + else -> { + // Fallback: encode NULL to avoid guessing structure + encodeParameters(NothingSerializer(), null) + } + } + } + else -> { + // Safe default for other known types if any + encodeParameters(NothingSerializer(), null) + } + } } override fun CompositeDecoder.decodeParameters(algorithm: ObjectIdentifier): KeyAlgorithmIdentifier = when (algorithm) { @@ -26,8 +46,14 @@ internal object KeyAlgorithmIdentifierSerializer : AlgorithmIdentifierSerializer } ObjectIdentifier.EC -> EcKeyAlgorithmIdentifier(decodeParameters(EcParameters.serializer())) else -> { - // TODO: somehow we should ignore parameters here - UnknownKeyAlgorithmIdentifier(algorithm) + // Capture unknown parameters as raw ASN.1 for round-trip when present; null means ABSENT + val raw: Asn1Any? = try { + decodeParameters(Asn1Any.serializer()) + } catch (_: IllegalStateException) { + // No element to read (ABSENT) + null + } + UnknownKeyAlgorithmIdentifier(algorithm, raw) } } -} \ No newline at end of file +} diff --git a/cryptography-serialization/asn1/modules/src/commonMain/kotlin/Oids.kt b/cryptography-serialization/asn1/modules/src/commonMain/kotlin/Oids.kt new file mode 100644 index 00000000..e58f517a --- /dev/null +++ b/cryptography-serialization/asn1/modules/src/commonMain/kotlin/Oids.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.asn1.modules + +import dev.whyoleg.cryptography.serialization.asn1.ObjectIdentifier + +public val ObjectIdentifier.Companion.Ed25519: ObjectIdentifier get() = ObjectIdentifier("1.3.101.112") +public val ObjectIdentifier.Companion.Ed448: ObjectIdentifier get() = ObjectIdentifier("1.3.101.113") + +public val ObjectIdentifier.Companion.X25519: ObjectIdentifier get() = ObjectIdentifier("1.3.101.110") +public val ObjectIdentifier.Companion.X448: ObjectIdentifier get() = ObjectIdentifier("1.3.101.111") + +internal fun ObjectIdentifier.isRfc8410NoParams(): Boolean = + this == ObjectIdentifier.Ed25519 || + this == ObjectIdentifier.Ed448 || + this == ObjectIdentifier.X25519 || + this == ObjectIdentifier.X448 diff --git a/cryptography-serialization/asn1/src/commonMain/kotlin/Any.kt b/cryptography-serialization/asn1/src/commonMain/kotlin/Any.kt new file mode 100644 index 00000000..1c16dc92 --- /dev/null +++ b/cryptography-serialization/asn1/src/commonMain/kotlin/Any.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.asn1 + +import kotlinx.serialization.Serializable + +/** + * Represents a raw ASN.1 element (tag + length + value) captured as-is. + * Useful for preserving unknown parameters for round-trip encoding. + */ +@Serializable +public class Asn1Any(public val bytes: ByteArray) + diff --git a/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerDecoder.kt b/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerDecoder.kt index 7e01c5e6..5b617248 100644 --- a/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerDecoder.kt +++ b/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerDecoder.kt @@ -29,32 +29,7 @@ internal class DerDecoder( return tag } - override fun decodeElementIndex(descriptor: SerialDescriptor): Int { - if (input.eof) return CompositeDecoder.DECODE_DONE - - val tag = input.peakTag() - - while (true) { - val index = currentIndex - tagOverride = descriptor.getElementContextSpecificTag(index) - - if (descriptor.isElementOptional(index)) { - val requiredTag = checkNotNull(tagOverride) { - "Optional element $descriptor[$index] must have context specific tag" - } - - // if the tag is different, - // then an optional element is absent, - // and so we need to increment the index - if (tag != requiredTag.tag) { - currentIndex++ - continue - } - } - - return currentIndex++ - } - } + override fun decodeNotNullMark(): Boolean = input.isNotNull() override fun decodeNull(): Nothing? = input.readNull() @@ -69,18 +44,47 @@ internal class DerDecoder( BitArray.serializer().descriptor -> input.readBitString(getAndResetTagOverride()) as T ObjectIdentifier.serializer().descriptor -> input.readObjectIdentifier(getAndResetTagOverride()) as T BigInt.serializer().descriptor -> input.readInteger(getAndResetTagOverride()) as T + Asn1Any.serializer().descriptor -> Asn1Any(input.readAnyElement(getAndResetTagOverride())) as T else -> deserializer.deserialize(this) } // structures: SEQUENCE and SEQUENCE OF override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = when (descriptor.kind) { - StructureKind.CLASS, is PolymorphicKind -> DerDecoder(der, input.readSequence(getAndResetTagOverride())) - else -> throw SerializationException("This serial kind is not supported as structure: $descriptor") + StructureKind.CLASS, is PolymorphicKind, StructureKind.LIST -> DerDecoder(der, input.readSequence(getAndResetTagOverride())) + else -> throw SerializationException("This serial kind is not supported as structure: $descriptor") } override fun decodeInline(descriptor: SerialDescriptor): Decoder = this override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int): Decoder = this + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + if (descriptor.kind == StructureKind.LIST) { + return if (input.eof) CompositeDecoder.DECODE_DONE else currentIndex++ + } + if (input.eof) return CompositeDecoder.DECODE_DONE + + val tag = input.peakTag() + + while (true) { + val index = currentIndex + if (index >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + tagOverride = descriptor.getElementContextSpecificTag(index) + + if (descriptor.isElementOptional(index)) { + val requiredTag = checkNotNull(tagOverride) { + "Optional element $descriptor[$index] must have context specific tag" + } + + if (tag != requiredTag.tag) { + currentIndex++ + continue + } + } + + return currentIndex++ + } + } + // could be supported, but later when it will be needed override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = error("Enum decoding is not supported") override fun decodeString(): String = error("String decoding is not supported") diff --git a/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerEncoder.kt b/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerEncoder.kt index 682b7168..e96a8295 100644 --- a/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerEncoder.kt +++ b/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerEncoder.kt @@ -49,13 +49,14 @@ internal class DerEncoder( BitArray.serializer().descriptor -> output.writeBitString(getAndResetTagOverride(), value as BitArray) ObjectIdentifier.serializer().descriptor -> output.writeObjectIdentifier(getAndResetTagOverride(), value as ObjectIdentifier) BigInt.serializer().descriptor -> output.writeInteger(getAndResetTagOverride(), value as BigInt) + Asn1Any.serializer().descriptor -> output.writeAnyRaw((value as Asn1Any).bytes) else -> serializer.serialize(this, value) } // structures: SEQUENCE and SEQUENCE OF override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder = when (descriptor.kind) { - StructureKind.CLASS, is PolymorphicKind -> DerEncoder(der, ByteArrayOutput(), output) - else -> throw SerializationException("This serial kind is not supported as structure: $descriptor") + StructureKind.CLASS, is PolymorphicKind, StructureKind.LIST -> DerEncoder(der, ByteArrayOutput(), output) + else -> throw SerializationException("This serial kind is not supported as structure: $descriptor") } override fun endStructure(descriptor: SerialDescriptor) { diff --git a/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerInput.kt b/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerInput.kt index 6a930539..148d8001 100644 --- a/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerInput.kt +++ b/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerInput.kt @@ -64,6 +64,23 @@ internal class DerInput(private val input: ByteArrayInput) { val length = readLength() readSlice(length) } + + // Read arbitrary ASN.1 element (tag + length + content) as raw bytes. + // Currently used for capturing unknown AlgorithmIdentifier parameters. + fun readAnyElement(tagOverride: ContextSpecificTag?): ByteArray { + // No context-specific override use-case for ANY in current structures. + check(tagOverride == null) { "Context-specific override is not supported for ANY" } + + val tag = input.read() + val length = input.readLength() + val content = input.read(length) + + val out = ByteArrayOutput() + out.write(tag) + writeLength(out, length) + out.write(content) + return out.toByteArray() + } } private inline fun ByteArrayInput.readTagWithOverride( @@ -131,3 +148,14 @@ private fun ByteArrayInput.readOidElement(): Int { check(element >= 0) { "element overflow: $element" } return element } + +// local helper to encode DER length bytes +private fun writeLength(out: ByteArrayOutput, length: Int) { + if (length < 128) { + out.write(length) + return + } + val numberOfLengthBytes = Int.SIZE_BYTES - length.countLeadingZeroBits() / 8 + out.write(numberOfLengthBytes or 0b10000000) + repeat(numberOfLengthBytes) { out.write(length ushr 8 * (numberOfLengthBytes - 1 - it)) } +} diff --git a/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerOutput.kt b/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerOutput.kt index 4d104e4a..12e0761f 100644 --- a/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerOutput.kt +++ b/cryptography-serialization/asn1/src/commonMain/kotlin/internal/DerOutput.kt @@ -47,6 +47,10 @@ internal class DerOutput(private val output: ByteArrayOutput) { } } + fun writeAnyRaw(bytes: ByteArray) { + // Write raw TLV bytes as-is + output.write(bytes) + } } private inline fun ByteArrayOutput.writeTagWithOverride( From fa128fd6b0262a970880113f9eb446622b5fc2c6 Mon Sep 17 00:00:00 2001 From: Aria Wisp Date: Sun, 7 Sep 2025 11:45:09 -0600 Subject: [PATCH 2/5] =?UTF-8?q?asn1(tests):=20add=20RFC=208410=20encode/de?= =?UTF-8?q?code=20and=20round=E2=80=91trip=20tests;=20SPKI=20decode;=20lis?= =?UTF-8?q?t=20encoding;=20negative=20validations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KeyAlgorithmIdentifierDecodeTest.kt | 89 +++++++++++++++++++ .../KeyAlgorithmIdentifierEncodeTest.kt | 53 +++++++++++ .../kotlin/SubjectPublicKeyInfoRfc8410Test.kt | 35 ++++++++ .../src/commonTest/kotlin/ListEncodingTest.kt | 34 +++++++ .../commonTest/kotlin/NegativeDecodingTest.kt | 83 +++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 cryptography-serialization/asn1/modules/src/commonTest/kotlin/KeyAlgorithmIdentifierDecodeTest.kt create mode 100644 cryptography-serialization/asn1/modules/src/commonTest/kotlin/KeyAlgorithmIdentifierEncodeTest.kt create mode 100644 cryptography-serialization/asn1/modules/src/commonTest/kotlin/SubjectPublicKeyInfoRfc8410Test.kt create mode 100644 cryptography-serialization/asn1/src/commonTest/kotlin/ListEncodingTest.kt create mode 100644 cryptography-serialization/asn1/src/commonTest/kotlin/NegativeDecodingTest.kt diff --git a/cryptography-serialization/asn1/modules/src/commonTest/kotlin/KeyAlgorithmIdentifierDecodeTest.kt b/cryptography-serialization/asn1/modules/src/commonTest/kotlin/KeyAlgorithmIdentifierDecodeTest.kt new file mode 100644 index 00000000..0b6b1ce7 --- /dev/null +++ b/cryptography-serialization/asn1/modules/src/commonTest/kotlin/KeyAlgorithmIdentifierDecodeTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.asn1.modules + +import dev.whyoleg.cryptography.serialization.asn1.* +import kotlinx.serialization.decodeFromByteArray +import kotlin.test.* + +class KeyAlgorithmIdentifierDecodeTest { + + private fun String.hexToBytes(): ByteArray { + check(length % 2 == 0) { "Invalid hex length" } + return ByteArray(length / 2) { i -> + val hi = this[i * 2].digitToInt(16) + val lo = this[i * 2 + 1].digitToInt(16) + ((hi shl 4) or lo).toByte() + } + } + + @Test + fun decode_Ed25519_absentParameters() { + // SEQUENCE { algorithm OBJECT IDENTIFIER 1.3.101.112 } (no parameters element) + val bytes = "300506032B6570".hexToBytes() + val id = Der.decodeFromByteArray(bytes) + assertTrue(id is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier.Ed25519, id.algorithm) + } + + @Test + fun decode_Ed25519_nullParameters() { + // SEQUENCE { algorithm OBJECT IDENTIFIER 1.3.101.112, parameters NULL } + val bytes = "300706032B65700500".hexToBytes() + val id = Der.decodeFromByteArray(bytes) + assertTrue(id is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier.Ed25519, id.algorithm) + } + + @Test + fun decode_X25519_absentParameters() { + // SEQUENCE { algorithm OBJECT IDENTIFIER 1.3.101.110 } (no parameters element) + val bytes = "300506032B656E".hexToBytes() + val id = Der.decodeFromByteArray(bytes) + assertTrue(id is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier.X25519, id.algorithm) + } + + @Test + fun decode_X25519_nullParameters() { + // SEQUENCE { algorithm OBJECT IDENTIFIER 1.3.101.110, parameters NULL } + val bytes = "300706032B656E0500".hexToBytes() + val id = Der.decodeFromByteArray(bytes) + assertTrue(id is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier.X25519, id.algorithm) + } + + @Test + fun decode_Ed448_absentParameters() { + val bytes = "300506032B6571".hexToBytes() + val id = Der.decodeFromByteArray(bytes) + assertTrue(id is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier.Ed448, id.algorithm) + } + + @Test + fun decode_Ed448_nullParameters() { + val bytes = "300706032B65710500".hexToBytes() + val id = Der.decodeFromByteArray(bytes) + assertTrue(id is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier.Ed448, id.algorithm) + } + + @Test + fun decode_X448_absentParameters() { + val bytes = "300506032B6570".replace("70","6F").hexToBytes() // 1.3.101.111 + val id = Der.decodeFromByteArray(bytes) + assertTrue(id is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier.X448, id.algorithm) + } + + @Test + fun decode_X448_nullParameters() { + val bytes = "300706032B656F0500".hexToBytes() + val id = Der.decodeFromByteArray(bytes) + assertTrue(id is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier.X448, id.algorithm) + } +} diff --git a/cryptography-serialization/asn1/modules/src/commonTest/kotlin/KeyAlgorithmIdentifierEncodeTest.kt b/cryptography-serialization/asn1/modules/src/commonTest/kotlin/KeyAlgorithmIdentifierEncodeTest.kt new file mode 100644 index 00000000..553de0b5 --- /dev/null +++ b/cryptography-serialization/asn1/modules/src/commonTest/kotlin/KeyAlgorithmIdentifierEncodeTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.asn1.modules + +import dev.whyoleg.cryptography.serialization.asn1.* +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.decodeFromByteArray +import kotlin.test.* + +class KeyAlgorithmIdentifierEncodeTest { + + private fun ByteArray.toHex() = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + private fun String.hexToBytes(): ByteArray { + check(length % 2 == 0) + return ByteArray(length / 2) { i -> + val hi = this[i * 2].digitToInt(16) + val lo = this[i * 2 + 1].digitToInt(16) + ((hi shl 4) or lo).toByte() + } + } + + @Test + fun encode_Ed25519_absentParameters() { + val id: KeyAlgorithmIdentifier = UnknownKeyAlgorithmIdentifier(ObjectIdentifier.Ed25519) + val bytes = Der.encodeToByteArray(id) + assertEquals("300506032b6570", bytes.toHex()) + } + + @Test + fun encode_X25519_absentParameters() { + val id: KeyAlgorithmIdentifier = UnknownKeyAlgorithmIdentifier(ObjectIdentifier.X25519) + val bytes = Der.encodeToByteArray(id) + assertEquals("300506032b656e", bytes.toHex()) + } + + @Test + fun encode_RSA_nullParameters() { + val id: KeyAlgorithmIdentifier = RsaKeyAlgorithmIdentifier + val bytes = Der.encodeToByteArray(id) + assertEquals("300d06092a864886f70d0101010500", bytes.toHex()) + } + + @Test + fun roundTrip_Ed25519_null_normalizedToAbsent() { + val withNull = "300706032b65700500".hexToBytes() + val id = Der.decodeFromByteArray(withNull) + val reencoded = Der.encodeToByteArray(id) + assertEquals("300506032b6570", reencoded.toHex()) + } +} + diff --git a/cryptography-serialization/asn1/modules/src/commonTest/kotlin/SubjectPublicKeyInfoRfc8410Test.kt b/cryptography-serialization/asn1/modules/src/commonTest/kotlin/SubjectPublicKeyInfoRfc8410Test.kt new file mode 100644 index 00000000..0ef06783 --- /dev/null +++ b/cryptography-serialization/asn1/modules/src/commonTest/kotlin/SubjectPublicKeyInfoRfc8410Test.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.asn1.modules + +import dev.whyoleg.cryptography.serialization.asn1.* +import kotlinx.serialization.decodeFromByteArray +import kotlin.test.* + +class SubjectPublicKeyInfoRfc8410Test { + private fun String.hexToBytes(): ByteArray { + check(length % 2 == 0) + return ByteArray(length / 2) { i -> + val hi = this[i * 2].digitToInt(16) + val lo = this[i * 2 + 1].digitToInt(16) + ((hi shl 4) or lo).toByte() + } + } + + @Test + fun spki_Ed25519_absentParameters() { + val bytes = "300a300506032b6570030100".hexToBytes() + val spki = Der.decodeFromByteArray(bytes) + assertEquals(ObjectIdentifier.Ed25519, spki.algorithm.algorithm) + } + + @Test + fun spki_Ed25519_nullParameters() { + val bytes = "300c300706032b65700500030100".hexToBytes() + val spki = Der.decodeFromByteArray(bytes) + assertEquals(ObjectIdentifier.Ed25519, spki.algorithm.algorithm) + } +} + diff --git a/cryptography-serialization/asn1/src/commonTest/kotlin/ListEncodingTest.kt b/cryptography-serialization/asn1/src/commonTest/kotlin/ListEncodingTest.kt new file mode 100644 index 00000000..514279bb --- /dev/null +++ b/cryptography-serialization/asn1/src/commonTest/kotlin/ListEncodingTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.asn1 + +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlin.test.* + +class ListEncodingTest { + private fun ByteArray.toHex() = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + + @Test + fun encode_sequenceOf_int_topLevel() { + val list = listOf(1, 2, 3) + val bytes = Der.encodeToByteArray(ListSerializer(Int.serializer()), list) + assertEquals("3009020101020102020103", bytes.toHex()) + } + + @Test + fun encode_sequenceOf_int_empty() { + val list = emptyList() + val bytes = Der.encodeToByteArray(ListSerializer(Int.serializer()), list) + assertEquals("3000", bytes.toHex()) + } + + @Test + fun decode_sequenceOf_int_topLevel() { + val bytes = "3009020101020102020103".chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val list = Der.decodeFromByteArray(ListSerializer(Int.serializer()), bytes) + assertEquals(listOf(1, 2, 3), list) + } +} diff --git a/cryptography-serialization/asn1/src/commonTest/kotlin/NegativeDecodingTest.kt b/cryptography-serialization/asn1/src/commonTest/kotlin/NegativeDecodingTest.kt new file mode 100644 index 00000000..6c4b629e --- /dev/null +++ b/cryptography-serialization/asn1/src/commonTest/kotlin/NegativeDecodingTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.asn1 + +import dev.whyoleg.cryptography.serialization.asn1.ContextSpecificTag.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlin.test.* + +class NegativeDecodingTest { + + @Test + fun wrongTagForInteger() { + // OCTET STRING (0x04) with one byte content 0x01 + val bytes = byteArrayOf(0x04, 0x01, 0x01).map { it.toByte() }.toByteArray() + assertFailsWith { + Der.decodeFromByteArray(bytes) + } + } + + @Test + fun invalidLengthZeroInLongForm() { + // OID (0x06) with long-form length 0x82 0x00 0x00 -> illegal zero length + val bytes = byteArrayOf(0x06, 0x82.toByte(), 0x00, 0x00).map { it.toByte() }.toByteArray() + assertFailsWith { + Der.decodeFromByteArray(bytes) + } + } + + @Serializable + class MandatoryImplicit( + @ContextSpecificTag(0, TagType.IMPLICIT) + val x: Int, + ) + + @Test + fun contextSpecificMandatoryTagMismatch() { + // Encoded value only for tag [1] IMPLICIT with INTEGER 8 + val sequence = byteArrayOf(0x30, 0x03, 0x81.toByte(), 0x01, 0x08).map { it.toByte() }.toByteArray() + assertFailsWith { + Der.decodeFromByteArray(sequence) + } + } + + @Serializable + class ExplicitInt( + @ContextSpecificTag(0, TagType.EXPLICIT) + val x: Int, + ) + + @Test + fun contextSpecificExplicitInnerTagMismatch() { + // SEQUENCE { [0] EXPLICIT { OCTET STRING 0x01 } } but Int expects INTEGER inside EXPLICIT + val seq = byteArrayOf( + 0x30, 0x05, // SEQUENCE, len 5 + 0xA0.toByte(), 0x03, // [0] EXPLICIT, len 3 + 0x04, 0x01, 0x01 // OCTET STRING, len 1, 0x01 + ) + assertFailsWith { + Der.decodeFromByteArray(seq) + } + } + + @Test + fun bitStringEmptyWithNonZeroUnusedBits() { + // BIT STRING: length 1, unusedBits = 1, no payload + val bs = byteArrayOf(0x03, 0x01, 0x01) + assertFailsWith { + Der.decodeFromByteArray(bs) + } + } + + @Test + fun bitStringUnusedBitsExceedsTrailingZeros() { + // BIT STRING: unusedBits=1, payload last byte 0x01 (no trailing zeros) + val bs = byteArrayOf(0x03, 0x02, 0x01, 0x01) + assertFailsWith { + Der.decodeFromByteArray(bs) + } + } +} From 8fe6dc14e071a859731f8403764e7c855c69c8e3 Mon Sep 17 00:00:00 2001 From: Aria Wisp Date: Sun, 7 Sep 2025 11:35:41 -0600 Subject: [PATCH 3/5] ci: add focused ASN.1 test matrix (JVM/JS/Wasm Node/Linux x64) and integrate into checks pipeline --- .github/workflows/run-checks.yml | 6 +++++- .github/workflows/run-tests-asn1.yml | 30 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/run-tests-asn1.yml diff --git a/.github/workflows/run-checks.yml b/.github/workflows/run-checks.yml index 4bba8c98..e0c8abdf 100644 --- a/.github/workflows/run-checks.yml +++ b/.github/workflows/run-checks.yml @@ -17,8 +17,12 @@ jobs: needs: [ build-project ] uses: ./.github/workflows/run-tests-default.yml + asn1-tests: + needs: [ build-project ] + uses: ./.github/workflows/run-tests-asn1.yml + compatibility-tests: - needs: [ default-tests ] + needs: [ default-tests, asn1-tests ] uses: ./.github/workflows/run-tests-compatibility.yml slow-tasks: diff --git a/.github/workflows/run-tests-asn1.yml b/.github/workflows/run-tests-asn1.yml new file mode 100644 index 00000000..af64ccb8 --- /dev/null +++ b/.github/workflows/run-tests-asn1.yml @@ -0,0 +1,30 @@ +name: Run ASN.1 tests +on: + workflow_dispatch: + workflow_call: + +defaults: + run: + shell: bash + +jobs: + asn1-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + task: + - ':cryptography-serialization-asn1:jvmTest' + - ':cryptography-serialization-asn1:jsTest' + - ':cryptography-serialization-asn1:wasmJsNodeTest' + - ':cryptography-serialization-asn1:linuxX64Test' + - ':cryptography-serialization-asn1-modules:jvmTest' + - ':cryptography-serialization-asn1-modules:jsTest' + - ':cryptography-serialization-asn1-modules:wasmJsNodeTest' + - ':cryptography-serialization-asn1-modules:linuxX64Test' + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-environment + - name: Run ${{ matrix.task }} + run: ./gradlew -q ${{ matrix.task }} --continue + From 2fb5a2658553ffa71aa96b2e8ae5f929cfa5e53e Mon Sep 17 00:00:00 2001 From: Aria Wisp Date: Sun, 7 Sep 2025 11:57:37 -0600 Subject: [PATCH 4/5] =?UTF-8?q?asn1(tests/docs):=20add=20unknown=20OID=20A?= =?UTF-8?q?sn1Any=20round=E2=80=91trip=20test;=20clarify=20Asn1Any=20KDoc;?= =?UTF-8?q?=20fix=20AlgorithmIdentifier=20deserializer=20to=20allow=20ABSE?= =?UTF-8?q?NT=20params=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/AlgorithmIdentifierSerializer.kt | 18 ++++++-- ...nownKeyAlgorithmIdentifierRoundTripTest.kt | 46 +++++++++++++++++++ .../asn1/src/commonMain/kotlin/Any.kt | 8 ++-- 3 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 cryptography-serialization/asn1/modules/src/commonTest/kotlin/UnknownKeyAlgorithmIdentifierRoundTripTest.kt diff --git a/cryptography-serialization/asn1/modules/src/commonMain/kotlin/AlgorithmIdentifierSerializer.kt b/cryptography-serialization/asn1/modules/src/commonMain/kotlin/AlgorithmIdentifierSerializer.kt index a1514747..dca3a42b 100644 --- a/cryptography-serialization/asn1/modules/src/commonMain/kotlin/AlgorithmIdentifierSerializer.kt +++ b/cryptography-serialization/asn1/modules/src/commonMain/kotlin/AlgorithmIdentifierSerializer.kt @@ -45,9 +45,17 @@ public abstract class AlgorithmIdentifierSerializer : index = 0, deserializer = ObjectIdentifier.serializer() ) - check(decodeElementIndex(descriptor) == 1) - val parameters = decodeParameters(algorithm) - check(decodeElementIndex(descriptor) == CompositeDecoder.DECODE_DONE) - parameters + when (val idx = decodeElementIndex(descriptor)) { + 1 -> { + val parameters = decodeParameters(algorithm) + check(decodeElementIndex(descriptor) == CompositeDecoder.DECODE_DONE) + parameters + } + CompositeDecoder.DECODE_DONE -> { + // Some algorithms may omit parameters. Delegate to subclass without consuming parameters. + decodeParameters(algorithm) + } + else -> error("Unexpected element index: $idx") + } } -} \ No newline at end of file +} diff --git a/cryptography-serialization/asn1/modules/src/commonTest/kotlin/UnknownKeyAlgorithmIdentifierRoundTripTest.kt b/cryptography-serialization/asn1/modules/src/commonTest/kotlin/UnknownKeyAlgorithmIdentifierRoundTripTest.kt new file mode 100644 index 00000000..36d5e8c1 --- /dev/null +++ b/cryptography-serialization/asn1/modules/src/commonTest/kotlin/UnknownKeyAlgorithmIdentifierRoundTripTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.asn1.modules + +import dev.whyoleg.cryptography.serialization.asn1.* +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlin.test.* + +class UnknownKeyAlgorithmIdentifierRoundTripTest { + + private fun String.hexToBytes(): ByteArray { + check(length % 2 == 0) + return ByteArray(length / 2) { i -> + val hi = this[i * 2].digitToInt(16) + val lo = this[i * 2 + 1].digitToInt(16) + ((hi shl 4) or lo).toByte() + } + } + + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + + @Test + fun roundTrip_unknownOid_withNonNullParams_preservesRawTlv() { + // AlgorithmIdentifier ::= SEQUENCE { + // algorithm OBJECT IDENTIFIER 1.2.3.4 (06 03 2A 03 04) + // parameters OCTET STRING 0xDE 0xAD (04 02 DE AD) + // } + val ai = "300906032a03040402dead".hexToBytes() + + val decoded = Der.decodeFromByteArray(ai) + assertTrue(decoded is UnknownKeyAlgorithmIdentifier) + assertEquals(ObjectIdentifier("1.2.3.4"), decoded.algorithm) + + val params = decoded.parameters + assertNotNull(params) + assertTrue(params is Asn1Any) + assertEquals("0402dead", params.bytes.toHex()) + + // Re-encode should be byte-for-byte identical + val re = Der.encodeToByteArray(KeyAlgorithmIdentifier.serializer(), decoded) + assertEquals(ai.toHex(), re.toHex()) + } +} diff --git a/cryptography-serialization/asn1/src/commonMain/kotlin/Any.kt b/cryptography-serialization/asn1/src/commonMain/kotlin/Any.kt index 1c16dc92..d81f8e4a 100644 --- a/cryptography-serialization/asn1/src/commonMain/kotlin/Any.kt +++ b/cryptography-serialization/asn1/src/commonMain/kotlin/Any.kt @@ -7,9 +7,11 @@ package dev.whyoleg.cryptography.serialization.asn1 import kotlinx.serialization.Serializable /** - * Represents a raw ASN.1 element (tag + length + value) captured as-is. - * Useful for preserving unknown parameters for round-trip encoding. + * Represents a raw ASN.1 element captured as-is. + * + * Notes: + * - [bytes] contains the full TLV (Tag + Length + Value), not only the value bytes. + * - Useful for preserving unknown parameters for exact round‑trip encoding. */ @Serializable public class Asn1Any(public val bytes: ByteArray) - From 348a6281512bf8e36b5e2123d14df7994e512448 Mon Sep 17 00:00:00 2001 From: Aria Wisp Date: Sun, 7 Sep 2025 12:03:59 -0600 Subject: [PATCH 5/5] docs(changelog): note ABI change for UnknownKeyAlgorithmIdentifier; mention ABSENT-parameters deserializer path --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43cadae5..0f8a7821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ - Unknown AlgorithmIdentifier parameters are preserved as raw ASN.1 for round-trip via new `Asn1Any` type. - Support SEQUENCE OF (list) encode/decode in DER codec. +#### Breaking changes + +- `UnknownKeyAlgorithmIdentifier` now carries `parameters: Any?` (previously always `null`) and the constructor signature changed to + `UnknownKeyAlgorithmIdentifier(algorithm: ObjectIdentifier, parameters: Any? = null)`. This is an ABI change in + `cryptography-serialization-asn1-modules`. Source usage that previously constructed the class with a single + `algorithm` argument remains valid. + +#### Other improvements + +- AlgorithmIdentifier base deserializer now permits an ABSENT-parameters path (subclass constructs the value without consuming + a parameters element), improving compatibility with inputs that omit parameters. + ## 0.5.0 – CryptoKit & optimal providers