Skip to content

Integration with kotlinx.serialization

This page shows how to use awesn1 with kotlinx.serialization. DER format support is provided by the discrete kxs module.

Core awesn1 types are serializable. When encoded with awesn1's DER format, they use proper ASN.1 TLV/DER encoding. When encoded with non-DER formats, fallback representations are used.

Default DER Registry

The default DER instance is immutable once it has been initialized, but its serializers module can be extended before that first use through an opt-in registry.

This exists for a practical reason: higher-level models often keep raw ASN.1 backing fields and derive transient semantic fields from those raw elements. If those transient fields need to decode through the default DER instance, the relevant contextual or open-polymorphic serializers must already be present without forcing every caller to manually rebuild the format.

The contract is intentionally strict:

  • default-DER contributors must register before the first access to DER
  • after the default DER instance has been initialized, further registrations throw
  • Der itself stays immutable; only the pre-initialization contributor list is extensible

Typical reasons to add contributors are domain-specific open polymorphism and raw-backed semantic wrappers. One concrete example is introducing new ASN.1 signature formats beyond awesn1's built-in SignatureValue subtypes: those additional serializers must be registered before the default DER instance is first used.

SignatureValue Registration

awesn1 keeps the DER registry generic. Built-in SignatureValue support must be manually installed when using the crypto module by calling DerDefaults.registerDerSerializers() before any call to DER!
Signum's own mandatory serialization hook calls it by default: registerSignumDefaultDerSerializers().

Sketch:

@Serializable(with = Ed448SignatureValue.Companion::class)
class Ed448SignatureValue(
    val octets: Asn1OctetString,
) : SignatureValue, Asn1Encodable<Asn1Primitive> {
    override fun encodeToTlv(): Asn1Primitive = octets.encodeToTlv()

    companion object : Asn1Serializable<Asn1Primitive, Ed448SignatureValue> {
        override val leadingTags = setOf(Asn1Element.Tag.OCTET_STRING)

        override fun doDecode(src: Asn1Primitive) =
            Ed448SignatureValue(src.asAsn1OctetString())
    }
}

@OptIn(ExperimentalSerializationApi::class)
fun registerEd448ForDefaultDer() {
    registerSignatureValueForDefaultDer()
    DefaultDerSerializersModuleRegistry.register(
        SerializersModule {
            polymorphicByTag(
                SignatureValue::class,
                serialName = DEFAULT_DER_SIGNATURE_VALUE_SERIAL_NAME,
            ) {
                subtype<Ed448SignatureValue>(Asn1Element.Tag.OCTET_STRING)
            }
        }
    )
}

The important part is that the registration happens before the first access to DER. For a new signature family, first install the built-in SignatureValue hook from awesn1 crypto, then register your additional subtype mapping in the same pre-initialization phase.

This design avoids a mutable global codec while still allowing library integrations to make raw-backed transient materialization work out of the box.

Non-DER Fallback Representations
  • ObjectIdentifier serializes as dotted-decimal text (1.2.840...)
  • Asn1Integer serializes as decimal string (due to being arbitrary precision)
  • Asn1Real (Zero, PositiveInfinity, NegativeInfinity, Finite) serializes as string (due to being arbitrary precision)
  • Asn1String and concrete subtypes (UTF8, Universal, Visible, IA5, Teletex, BMP, General, Graphic, Unrestricted, Videotex, Printable, Numeric) serialize as plain string
  • Asn1Time serializes as plain Instant string form
  • Asn1BitString serializes as a string surrogate containing padding and Base64 payload
  • BitSet serializes as a bit-string view (101001...)
  • Asn1Element, Asn1Structure, Asn1ExplicitlyTagged, Asn1CustomStructure, Asn1EncapsulatingOctetString, Asn1PrimitiveOctetString, Asn1Set, Asn1SetOf, Asn1Primitive, and Asn1OctetString serialize as Base64-encoded DER bytes

Warning: Non-DER fallback serialization is intentionally lossy for Asn1String and Asn1Time for cross-format simplicity. Asn1String deserializes to UTF8 (original ASN.1 string subtype is not preserved), and Asn1Time deserializes from Instant only (original UTC TIME vs GENERALIZED TIME choice is not preserved where ranges overlap).

Maven Coordinates

implementation("at.asitplus.awesn1:kxs:$version")

Baseline Mapping

awesn1's DER codec makes @Serializable class work with ASN.1 automatically. Any serializable class maps to ASN.1 SEQUENCE by default, as shown below.

@Serializable
private data class TutorialDocPerson(
    val name: String,
    val age: Int,
)
val value = TutorialDocPerson(name = "A", age = 5)
val der = DER.encodeToByteArray(value)
check(der.toHexString() == "30060c0141020105") /* (1)! */
  1. Explore on asn1js.eu

Overriding Tags with @Asn1Tag

Use @Asn1Tag for implicit tag overrides when your wire format requires a specific context-specific tag number. This is common in profiles that refine generic ASN.1 structures into tightly specified field layouts. You will see this pattern throughout X.509 (RFC 5280), especially in extension and name-related structures.

@Serializable
private data class TutorialDocTaggedInt(
    @Asn1Tag(
        tagNumber = 0u,
        tagClass = Asn1TagClass.CONTEXT_SPECIFIC,
        constructed = Asn1ConstructedBit.PRIMITIVE,
    )
    val value: Int,
)
val value = TutorialDocTaggedInt(value = 5)
val der = DER.encodeToByteArray(value)
check(der.toHexString() == "3003800105") /* (1)! */
  1. Explore on asn1js.eu

Modelling EXPLICIT Wrappers

Use ExplicitlyTagged<T> with a constructed context-specific tag when the schema requires an extra wrapper layer around the actual value. This shows up in protocol designs that intentionally preserve type boundaries for forward compatibility or profile conformance. For examples of explicit tagging in broadly deployed PKI syntax, see CMS (RFC 5652).

@Serializable
private data class TutorialDocExplicitCarrier(
    @Asn1Tag(
        tagNumber = 0u,
        tagClass = Asn1TagClass.CONTEXT_SPECIFIC,
        constructed = Asn1ConstructedBit.CONSTRUCTED,
    )
    val wrapped: ExplicitlyTagged<Int>,
)
val value = TutorialDocExplicitCarrier(wrapped = ExplicitlyTagged(5))
val der = DER.encodeToByteArray(value)
check(der.toHexString() == "3005a003020105") /* (1)! */
  1. Explore on asn1js.eu

Modelling CHOICE with Sealed Types

Sealed polymorphism maps naturally to ASN.1 CHOICE, where one wire value can represent one of several subtypes. This is a direct fit for data families such as identity names, algorithm parameters, and extension payload variants. A common real-world example is GeneralName in X.509 (RFC 5280).

@Serializable
private sealed interface TutorialDocChoice

@Serializable
private data class TutorialDocChoiceInt(
    val value: Int,
) : TutorialDocChoice

@Serializable
@Asn1Tag(1337u)
private data class TutorialDocChoiceBool(
    val value: Boolean,
) : TutorialDocChoice
val der = DER.encodeToByteArray(value)
val derHex = der.toHexString()
when (value) {
    is TutorialDocChoiceInt -> check(derHex == "3003020107") /* (1)! */
    is TutorialDocChoiceBool -> check(derHex == "bf8a39030101ff") /* (2)! */
}
  1. Explore on asn1js.eu
  2. Explore on asn1js.eu

Primitive CHOICE Alternatives

When the CHOICE alternatives are just primitive wrappers, sealed inline value classes work as well. This keeps the Kotlin model compact while still allowing per-arm ASN.1 annotations where needed.

@Serializable
private sealed interface TutorialDocPrimitiveChoice

@Serializable
@JvmInline
private value class TutorialDocPrimitiveChoiceInt(
    val value: Int,
) : TutorialDocPrimitiveChoice

@Serializable
@JvmInline
@Asn1Tag(
    tagNumber = 0u,
    tagClass = Asn1TagClass.CONTEXT_SPECIFIC,
    constructed = Asn1ConstructedBit.PRIMITIVE,
)
private value class TutorialDocPrimitiveChoiceBool(
    val value: Boolean,
) : TutorialDocPrimitiveChoice

@Serializable
@JvmInline
private value class TutorialDocPrimitiveChoiceText(
    val value: String,
) : TutorialDocPrimitiveChoice
val der = DER.encodeToByteArray(value)
val derHex = der.toHexString()
when (value) {
    is TutorialDocPrimitiveChoiceInt -> check(derHex == /* (1)! */"020107")
    is TutorialDocPrimitiveChoiceBool -> check(derHex == /* (2)! */"8001ff")
    is TutorialDocPrimitiveChoiceText -> check(derHex == /* (3)! */"0c0141")
}
  1. Explore on asn1js.eu
  2. Explore on asn1js.eu
  3. Explore on asn1js.eu

Open Polymorphism by Leading Tag

When you have open polymorphism and subtypes are distinguishable by ASN.1 tag alone, dispatch by leading tag is the simplest robust option. This keeps type resolution local to the encoded element and avoids schema-specific side channels. This style appears in tagged alternatives in X.509 (RFC 5280) and related certificate ecosystems.

First, a non-value-class example modeled after RFC-style GeneralName alternatives (dNSName and uniformResourceIdentifier):

private interface TutorialDocGeneralNameByTag

@Serializable
@Asn1Tag(
    tagNumber = 2u,
    tagClass = Asn1TagClass.CONTEXT_SPECIFIC,
    constructed = Asn1ConstructedBit.PRIMITIVE,
)
@JvmInline
private value class TutorialDocGeneralNameDns(
    val value: String,
) : TutorialDocGeneralNameByTag

@Serializable
@Asn1Tag(
    tagNumber = 6u,
    tagClass = Asn1TagClass.CONTEXT_SPECIFIC,
    constructed = Asn1ConstructedBit.PRIMITIVE,
)
@JvmInline
private value class TutorialDocGeneralNameUri(
    val value: String,
) : TutorialDocGeneralNameByTag
val codec = DER {
    serializersModule = SerializersModule {
        polymorphicByTag(TutorialDocGeneralNameByTag::class, serialName = "TutorialDocGeneralNameByTag") {
            subtype<TutorialDocGeneralNameDns>()
            subtype<TutorialDocGeneralNameUri>()
        }
    }
}
val der = codec.encodeToByteArray(value)
val derHex = der.toHexString()
when (value) {
    is TutorialDocGeneralNameDns -> check(derHex == /* (1)! */"820b6578616d706c652e636f6d") { derHex }
    is TutorialDocGeneralNameUri -> check(derHex == /* (2)! */"861368747470733a2f2f6578616d706c652e636f6d") { derHex }
}
  1. Explore on asn1js.eu
  2. Explore on asn1js.eu

The non-value-class approach is usually preferable when each variant carries additional semantics beyond a single primitive field, for example, validation hooks, helper methods, or room for future schema growth. It also mirrors how many RFC text definitions are documented conceptually: named alternatives with explicit meaning, even if their payload is currently simple.

Value classes are still useful when a variant is intentionally a very thin wrapper around one scalar and you want the most compact model in Kotlin source. Both approaches use the exact same polymorphic-by-tag dispatch mechanism in awesn1; the difference is mostly about modeling style and maintainability constraints in your domain code.

Second, the same mechanism with value classes:

private interface TutorialDocOpenByTagValueClass

@Serializable
@JvmInline
private value class TutorialDocOpenByTagInt(
    val value: Int,
) : TutorialDocOpenByTagValueClass

@Serializable
@JvmInline
private value class TutorialDocOpenByTagBool(
    val value: Boolean,
) : TutorialDocOpenByTagValueClass
val codec = DER {
    serializersModule = SerializersModule {
        polymorphicByTag(TutorialDocOpenByTagValueClass::class, serialName = "TutorialDocOpenByTagValueClass") {
            subtype<TutorialDocOpenByTagInt>()
            subtype<TutorialDocOpenByTagBool>()
        }
    }
}
val der = codec.encodeToByteArray(value)
val derHex = der.toHexString()
when (value) {
    is TutorialDocOpenByTagInt -> check(derHex == "020109") /* (1)! */
    is TutorialDocOpenByTagBool -> check(derHex == "0101ff") /* (2)! */
}
  1. Explore on asn1js.eu
  2. Explore on asn1js.eu

Open Polymorphism by OID

For OID-based domains, dispatch by object identifier instead of by tag when multiple subtypes can share the same outer ASN.1 shape. This is the standard strategy for algorithm identifiers, extension payloads, and typed attribute value containers. Real-world references include PKCS #10 (RFC 2986), CMS (RFC 5652), and X.509 (RFC 5280).

private interface TutorialDocOpenByOid : Identifiable

@Serializable
private data class TutorialDocOpenByOidInt(
    val value: Int,
) : TutorialDocOpenByOid, Identifiable by Companion {
    companion object : OidProvider<TutorialDocOpenByOidInt> {
        @OptIn(ExperimentalUuidApi::class)
        override val oid: ObjectIdentifier = ObjectIdentifier(Uuid.parse("4932c522-dfce-453a-8c92-d792c0e50147"))
    }
}
val codec = DER {
    serializersModule = SerializersModule {
        polymorphicByOid(TutorialDocOpenByOid::class, serialName = "TutorialDocOpenByOid") {
            subtype<TutorialDocOpenByOidInt>(TutorialDocOpenByOidInt)
        }
    }
}
val der = codec.encodeToByteArray(value)
check(der.toHexString() == /* (1)! */"30190614698192b2e2c8dbfcf294f58cc9b5f2ac87948247020109")
  1. Explore on asn1js.eu

Collections: Map and Set

Default mappings for Map and Set are supported, so idiomatic Kotlin collection models can be encoded without custom serializers in many cases. This is useful for attribute bags, extension dictionaries, and grouped values that naturally map to ASN.1 collection constructs. In PKI and signed-message standards, SET and sequence-of-entry patterns are common; see X.509 (RFC 5280) and CMS (RFC 5652).

  • Kotlin Set<T> maps to ASN.1 SET semantics.
  • Kotlin Map<K, V> is encoded as a structured collection of key/value entries.
@Serializable
private data class TutorialDocMapAndSet(
    val map: Map<Int, Int>,
    val set: Set<Int>,
)
val value = TutorialDocMapAndSet(
    map = mapOf(1 to 2),
    set = setOf(3),
)
val der = DER.encodeToByteArray(value)
check(der.toHexString() == /* (1)! */"300d30060201010201023103020103")
  1. Explore on asn1js.eu

Retaining and Re-Emitting Raw ASN.1 Data

This topic is about interoperability under non-ideal conditions: preserving exact input bytes when you cannot assume fully canonical upstream encoders. It matters in cryptographic workflows where signature input fidelity is as important as semantic correctness. You will encounter this in certificate validation stacks, trust service integrations, and large-scale protocol gateways.

Raw Asn1Set for Non-Canonical Input

Some systems produce ASN.1 SET elements with wrong DER member ordering. Decoding into a plain Kotlin Set loses the original wire order immediately, which is a problem if the raw data is needed, for example, for signature verification. If you must keep exact bytes for re-emission, model the property as raw Asn1Set and materialize your domain view via a @Transient Kotlin Set. This pattern is especially relevant for signature verification and audit trails where re-encoding must not normalize away sender-specific quirks.

@Serializable
@ConsistentCopyVisibility
private data class TutorialDocThirdPartyAlgorithms private constructor(
    val rawAlgorithms: Asn1Set,
) {
    constructor(algorithms: Set<ObjectIdentifier>) : this(
        rawAlgorithms = DER.encodeToTlv(algorithms).asSet()
    )

    @kotlinx.serialization.Transient
    val algorithms: Set<ObjectIdentifier> = DER.decodeFromDer(rawAlgorithms.derEncoded)
}
val oidShort = ObjectIdentifier("1.2.3")
val oidLong = ObjectIdentifier("1.2.840.113549.1.1.11")

val canonical = DER.encodeToByteArray(
    TutorialDocThirdPartyAlgorithms(
        algorithms = setOf(oidLong, oidShort)
    )
)
val canonicalHex = canonical.toHexString()
check(canonicalHex == /* (1)! */"3011310f06022a0306092a864886f70d01010b") { "canonicalHex=$canonicalHex" }

//sorting is messed up
val nonCanonical = "3011310f06092a864886f70d01010b06022a03".hexToByteArray()
val decoded = DER.decodeFromByteArray<TutorialDocThirdPartyAlgorithms>(nonCanonical)
check(decoded.algorithms == setOf(oidShort, oidLong))

val reencoded = DER.encodeToByteArray(decoded)
val reencodedHex = reencoded.toHexString()
check(reencodedHex == /* (2)! */"3011310f06092a864886f70d01010b06022a03") { "reencodedHex=$reencodedHex" }
  1. Canonical encoding from rich model (Asn1Set sorts by DER rules). Explore on asn1js.eu
  2. Re-encoding decoded third-party non-canonical input preserves original wrong order. Explore on asn1js.eu

Signed Data with Raw Payload Preservation

This example models a SignedBox envelope for signed data. The main requirement is signature verification: we need the exact original signature input as raw ASN.1, so we can always recover unmodified DER bytes. This mirrors practical requirements in detached signatures, timestamp containers, and certificate-based token systems. For standard background, see CMS (RFC 5652).

Real-world ASN.1 codecs (or rather: the business logic built on top) typically produce structurally valid data but are sometimes not perfectly spec-conformant at the encoding level. For example, production implementations exist that misencode TRUE or show other low-level flaws. See: encoding flaws documented by Warden Supreme for real-world examples at scale.

For this example, we assume ExamplePayload is a normal domain model defined elsewhere and reused in multiple contexts. In SignedBox, this payload must be implicitly tagged according to spec, but we also want to preserve it as raw Asn1Element. Directly combining implicit tagging and raw Asn1Element with kotlinx.serialization is intentionally prohibited because it creates ambiguous decoding semantics.

The pattern in this sample uses a value class to still get the job done:

  • RawTaggedPayload stores the raw implicitly tagged element for byte-exact re-use.
  • ImplicitlyTaggedPayload (value class with @Asn1Tag) provides the schema-level tagging contract.
  • a @Transient parsed value is materialized at instantiation time, so structurally invalid raw payloads are rejected immediately.
  • both payload and signature in SignedBox are implicitly tagged members.
  • strict rich decoding rejects known non-canonical encodings (for example BOOLEAN TRUE = 0x01) while still exposing the canonical raw payload element when decoding succeeds.

This pattern is the complex extension of the implicit-tagging workaround shown in ElementTaggingTest (ValueClassImplicitlyTaggedElement).

@Serializable
private data class ExamplePayload(
    val algorithmIdentifier: ObjectIdentifier,
    val creationTimeEpochSeconds: Long,
    val validUntilEpochSeconds: Long,
    val integrityFlag: Boolean,
    val payloadData: ByteArray,
)

@Serializable
@JvmInline
@Asn1Tag(tagNumber = 1u)
private value class ImplicitlyTaggedPayload(
    val value: ExamplePayload,
)

@Serializable
private data class SignedBox private constructor(
    val rawPayload: Asn1Element,
    @Asn1Tag(tagNumber = 2u)
    val signature: ByteArray,
) {

    //public constructor ensures only valid data is passed
    constructor(payload: ExamplePayload, signature: ByteArray) : this(
        rawPayload = DER.encodeToTlv(ImplicitlyTaggedPayload(payload)),
        signature = signature
    )

    //hidden from serialization, but eager init ensure the data is semantically correct
    //if you want to be even more lenient, mage it a getter that throws
    @Transient
    val payload: ExamplePayload = DER.decodeFromTlv<ImplicitlyTaggedPayload>(rawPayload).value


}
val payload = ExamplePayload(
    algorithmIdentifier = ObjectIdentifier("1.2.840.113549.1.1.11"),
    creationTimeEpochSeconds = 1_736_203_200,
    validUntilEpochSeconds = 1_767_739_200,
    integrityFlag = true,
    payloadData = "deadbeef".hexToByteArray(),
)
val canonicalBox = SignedBox(
    payload,
    signature = "0102030405".hexToByteArray(),
)

canonicalBox.rawPayload.tag shouldBe
        /* (1)!*/ Asn1Element.Tag(1uL, constructed = true, tagClass = TagClass.CONTEXT_SPECIFIC)

fun assertEquivalentPayload(decoded: ExamplePayload, payload: ExamplePayload) {
    decoded.algorithmIdentifier shouldBe payload.algorithmIdentifier
    decoded.creationTimeEpochSeconds shouldBe payload.creationTimeEpochSeconds
    decoded.validUntilEpochSeconds shouldBe payload.validUntilEpochSeconds
    decoded.integrityFlag shouldBe payload.integrityFlag
    decoded.payloadData.contentEquals(payload.payloadData) shouldBe true
}

val canonicalDer = DER.encodeToByteArray(canonicalBox)
canonicalDer.toHexString() shouldBe
        /* (2)! */ "3029a12006092a864886f70d01010b0204677c5bc00204695d8f400101ff0404deadbeef82050102030405"


val decodedCanonical = DER.decodeFromByteArray<SignedBox>(canonicalDer)
assertEquivalentPayload(decodedCanonical.payload, payload)
decodedCanonical.rawPayload.derEncoded.toHexString().contains("0101ff") shouldBe true

// Some in-the-wild encoders emit BOOLEAN TRUE as 0x01 instead of DER-canonical 0xFF.
val nonCanonical =
    DER.decodeFromByteArray<SignedBox>(canonicalDer.toHexString().replaceFirst("0101ff", "010101").hexToByteArray())

val nonCanonicalDer = DER.encodeToByteArray(nonCanonical)

//non-canonical boolean is kept: 0x00 = false, other single-byte values are considered true
nonCanonicalDer.toHexString() shouldBe
        /* (3)! */ "3029a12006092a864886f70d01010b0204677c5bc00204695d8f400101010404deadbeef82050102030405"
  1. As can be seen, implicit tagging is applied.
  2. Explore on asn1js.eu
  3. Explore on asn1js.eu

Format Options

DER format behaviour can be tuned with the DER builder:

  • explicitNulls = true: encode nullable null as ASN.1 NULL
  • encodeDefaults = false: omit default-valued properties

These switches are important when you need to align with profile-specific encoding expectations or with legacy systems that depend on a specific wire form. For strict canonicality expectations in certificate ecosystems, see X.509 (RFC 5280).

@Serializable
private data class TutorialDocNullableInt(
    val value: Int?,
)

@Serializable
private data class TutorialDocDefaults(
    val first: Int = 1,
    val second: Boolean = true,
)
val format = DER { explicitNulls = true }
val value = TutorialDocNullableInt(value = null)
val der = format.encodeToByteArray(value)
check(der.toHexString() == "30020500") /* (1)! */
val format = DER { encodeDefaults = false }
val value = TutorialDocDefaults()
val der = format.encodeToByteArray(value)
check(der.toHexString() == "3000") /* (2)! */
  1. Explore on asn1js.eu
  2. Explore on asn1js.eu

Asn1Serializer with Low-Level Types

This section explains when low-level ASN.1 model types are enough on their own and when they need an explicit bridge into the kotlinx.serialization world. The distinction matters in mixed codebases where some types are protocol-native and others are DTOs used by app-layer serialization pipelines.

Top-Level Low-Level Type Without Asn1Serializer

At top level, a type that implements Asn1Encodable and provides a matching Asn1Decodable companion can be encoded and decoded directly through low-level APIs. No kotlinx serializer bridge is needed yet, because this path does not rely on descriptor-driven property decoding. First the type and companion are defined, then the roundtrip shows the direct low-level call path.

private data class TutorialDocSemanticVersion(
    val major: Int,
    val minor: Int,
) : Asn1Encodable<Asn1Sequence> {
    override fun encodeToTlv(): Asn1Sequence = Asn1.Sequence {
        +Asn1.Int(major)
        +Asn1.Int(minor)
    }

    companion object : Asn1Decodable<Asn1Sequence, TutorialDocSemanticVersion> {
        override fun doDecode(src: Asn1Sequence): TutorialDocSemanticVersion =
            TutorialDocSemanticVersion(
                major = src.children[0].asPrimitive().decodeToInt(),
                minor = src.children[1].asPrimitive().decodeToInt(),
            )
    }
}
val value = TutorialDocSemanticVersion(major = 1, minor = 42)
val der = value.encodeToDer()
check(der.toHexString() == "300602010102012a") /* (1)! */
  1. Explore on asn1js.eu

Same Type as a Property Failing Without a Bridge

Now the same type is embedded as a property of a @Serializable carrier. At this point, awesn1 needs serializer metadata for property-level decoding decisions, and the low-level companion alone is not enough to satisfy that contract. The first snippet defines the carrier, and the second snippet demonstrates the failure path.

@Serializable
private data class TutorialDocAsn1SerializerMissingCarrier(
    @kotlinx.serialization.Contextual
    val version: TutorialDocSemanticVersion,
)
val value = TutorialDocAsn1SerializerMissingCarrier(
    version = TutorialDocSemanticVersion(major = 1, minor = 42)
)
val der = DER.encodeToByteArray(value)
shouldThrow<SerializationException> {
    DER.decodeFromByteArray<TutorialDocAsn1SerializerMissingCarrier>(der)
}

Lean Asn1Serializer Bridge

Asn1Serializer is an abstract helper that supplies the property-level metadata awesn1 needs and forwards decode logic to an existing Asn1Decodable companion, so there is no duplication of parsing logic. This keeps composition explicit: low-level encode/decode behaviour stays in the model type, while the bridge only adapts it to kotlinx descriptor-based workflows. The first snippet shows the lean bridge declaration plus annotated carrier; the second snippet shows successful roundtrip again.

private object TutorialDocSemanticVersionBridgeSerializer : Asn1Serializer<Asn1Sequence, TutorialDocSemanticVersion>(
    leadingTags = setOf(Asn1Element.Tag.SEQUENCE),
    TutorialDocSemanticVersion
)

@Serializable
private data class TutorialDocAsn1SerializerBridgeCarrier(
    @Serializable(with = TutorialDocSemanticVersionBridgeSerializer::class)
    val version: TutorialDocSemanticVersion,
)
val value = TutorialDocAsn1SerializerBridgeCarrier(
    version = TutorialDocSemanticVersion(major = 1, minor = 42)
)
val der = DER.encodeToByteArray(value)
check(der.toHexString() == "3008300602010102012a") /* (1)! */
  1. Explore on asn1js.eu

Deep Dive: Disambiguation

Disambiguation is the core safety mechanism that keeps decoding deterministic and secure. Many ASN.1 interoperability issues in production systems are not parse failures but ambiguous layouts that different implementations resolve differently. The following steps show how ambiguity appears and how to remove it explicitly.

Baseline: Three Non-Nullable Strings

This layout is deterministic: every field is always present. This is the safe baseline shape you find in tightly constrained profile fields where omissions are not allowed.

@Serializable
private data class TutorialDocThreeNames(
    val first: String,
    val middle: String,
    val last: String,
)
val value = TutorialDocThreeNames("A", "B", "C")
val der = DER.encodeToByteArray(value)
check(der.toHexString() == "30090c01410c01420c0143") /* (1)! */
  1. Explore on asn1js.eu

Nullable Strings with explicitNulls = true

Encoding null as ASN.1 NULL keeps field positions observable. That makes omission-vs-presence semantics explicit and avoids positional ambiguity when adjacent fields share tags.

@Serializable
private data class TutorialDocThreeNullableNames(
    val first: String?,
    val middle: String?,
    val last: String?,
)
val codec = DER { explicitNulls = true }
val value = TutorialDocThreeNullableNames("A", null, "C")
val der = codec.encodeToByteArray(value)
check(der.toHexString() == "30080c014105000c0143") /* (1)! */
  1. Explore on asn1js.eu

Nullable Strings with Omitted Nulls (explicitNulls = false)

Now null fields disappear from the wire. With same-shaped neighbors (String, String, String), omitted middle fields become undecidable, so serialization is rejected. Fail-fast behavior here prevents latent interoperability and security bugs in downstream decoders.

val value = TutorialDocThreeNullableNames("A", null, "C")
shouldThrow<SerializationException> {
    DER.encodeToByteArray(value) /* (1)! */
}

Ambiguity is detected early and rejected at encode-time.

Disambiguate with Implicit Tags

Assign distinct context-specific implicit tags (common in X.509). Now each field has a distinct leading tag, so omission is safe again. This is a primary real-world technique for making optional fields unambiguous in certificate and extension schemas.

@Serializable
private data class TutorialDocTaggedNullableNames(
    @Asn1Tag(0u, tagClass = Asn1TagClass.CONTEXT_SPECIFIC, constructed = Asn1ConstructedBit.PRIMITIVE)
    val first: String?,
    @Asn1Tag(1u, tagClass = Asn1TagClass.CONTEXT_SPECIFIC, constructed = Asn1ConstructedBit.PRIMITIVE)
    val middle: String?,
    @Asn1Tag(2u, tagClass = Asn1TagClass.CONTEXT_SPECIFIC, constructed = Asn1ConstructedBit.PRIMITIVE)
    val last: String?,
)
val value = TutorialDocTaggedNullableNames("A", null, "C")
val der = DER.encodeToByteArray(value)
check(der.toHexString() == "3006800141820143") /* (1)! */
  1. Explore on asn1js.eu

EXPLICIT tagging is another valid disambiguation strategy when schema/tooling requirements prefer wrapper elements.

Custom Serializers Re-Introducing Ambiguity

If two nullable custom-serialized types both resolve to SEQUENCE and no field tags are present, nullable omission can be ambiguous again and is rejected on encode. This is a common pitfall when composing reusable serializers that were written independently of each other.

@Serializable(with = TutorialDocComplexLeftSerializer::class)
private data class TutorialDocComplexLeft(val value: Int)

@Serializable(with = TutorialDocComplexRightSerializer::class)
private data class TutorialDocComplexRight(val value: Int)

@Serializable
private data class TutorialDocCustomComplexAmbiguous(
    val left: TutorialDocComplexLeft?,
    val right: TutorialDocComplexRight?,
)

private object TutorialDocComplexLeftSerializer : KSerializer<TutorialDocComplexLeft> {
    @Serializable
    private data class Surrogate(val value: Int)

    override val descriptor: SerialDescriptor = Surrogate.serializer().descriptor

    override fun serialize(encoder: Encoder, value: TutorialDocComplexLeft) {
        encoder.encodeSerializableValue(Surrogate.serializer(), Surrogate(value.value))
    }

    override fun deserialize(decoder: Decoder): TutorialDocComplexLeft =
        TutorialDocComplexLeft(decoder.decodeSerializableValue(Surrogate.serializer()).value)
}

private object TutorialDocComplexRightSerializer : KSerializer<TutorialDocComplexRight> {
    @Serializable
    private data class Surrogate(val value: Int)

    override val descriptor: SerialDescriptor = Surrogate.serializer().descriptor

    override fun serialize(encoder: Encoder, value: TutorialDocComplexRight) {
        encoder.encodeSerializableValue(Surrogate.serializer(), Surrogate(value.value))
    }

    override fun deserialize(decoder: Decoder): TutorialDocComplexRight =
        TutorialDocComplexRight(decoder.decodeSerializableValue(Surrogate.serializer()).value)
}
val value = TutorialDocCustomComplexAmbiguous(
    left = TutorialDocComplexLeft(1),
    right = null,
)
shouldThrow<SerializationException> {
    DER.encodeToByteArray(value) /* (1)! */
}

Disambiguating using Leading-Tag Metadata

When serializer descriptors expose precise leading tags (withAsn1LeadingTags / withDynamicAsn1LeadingTags), awesn1 can reason about field boundaries and accept otherwise risky nullable layouts. This lets you keep reusable serializer components while still meeting strict schema disambiguation requirements.

@Serializable(with = TutorialDocScalarLeftSerializer::class)
private data class TutorialDocScalarLeft(val value: Int)

@Serializable(with = TutorialDocScalarRightSerializer::class)
private data class TutorialDocScalarRight(val value: String)

@Serializable
private data class TutorialDocCustomByLeadingTags(
    val left: TutorialDocScalarLeft?,
    val right: TutorialDocScalarRight?,
)

private object TutorialDocScalarLeftSerializer : KSerializer<TutorialDocScalarLeft> {
    private val baseDescriptor = PrimitiveSerialDescriptor("TutorialDocScalarLeft", PrimitiveKind.INT)
    override val descriptor: SerialDescriptor =
        baseDescriptor.withAsn1LeadingTags(setOf(Asn1Element.Tag.INT))

    override fun serialize(encoder: Encoder, value: TutorialDocScalarLeft) {
        encoder.encodeInt(value.value)
    }

    override fun deserialize(decoder: Decoder): TutorialDocScalarLeft =
        TutorialDocScalarLeft(decoder.decodeInt())
}

private object TutorialDocScalarRightSerializer : KSerializer<TutorialDocScalarRight> {
    private val baseDescriptor = PrimitiveSerialDescriptor("TutorialDocScalarRight", PrimitiveKind.STRING)
    override val descriptor: SerialDescriptor =
        baseDescriptor.withDynamicAsn1LeadingTags { setOf(Asn1Element.Tag.STRING_UTF8) }

    override fun serialize(encoder: Encoder, value: TutorialDocScalarRight) {
        encoder.encodeString(value.value)
    }

    override fun deserialize(decoder: Decoder): TutorialDocScalarRight =
        TutorialDocScalarRight(decoder.decodeString())
}
val value = TutorialDocCustomByLeadingTags(
    left = null,
    right = TutorialDocScalarRight("B")
)
val der = DER.encodeToByteArray(value)
check(der.toHexString() == "30030c0142") /* (1)! */
  1. Explore on asn1js.eu

Now one might ask: why is there no ignoreUnknownElements in the spirit of the Json format's ignoreUnknownKeys. The reason is simple: it is not required and DER enforces strict rules to prevent ambiguities and ensure everything works well in cryptographic contexts. If you encounter a situation where this is needed, something is probably fishy and double-checking is recommended. Should this really be needed, resort to low-level ASN.1 decoding and/or model data differently.

See Also