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
DERinstance has been initialized, further registrations throw Deritself 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
ObjectIdentifierserializes as dotted-decimal text (1.2.840...)Asn1Integerserializes as decimal string (due to being arbitrary precision)Asn1Real(Zero,PositiveInfinity,NegativeInfinity,Finite) serializes as string (due to being arbitrary precision)Asn1Stringand concrete subtypes (UTF8,Universal,Visible,IA5,Teletex,BMP,General,Graphic,Unrestricted,Videotex,Printable,Numeric) serialize as plain stringAsn1Timeserializes as plainInstantstring formAsn1BitStringserializes as a string surrogate containing padding and Base64 payloadBitSetserializes as a bit-string view (101001...)Asn1Element,Asn1Structure,Asn1ExplicitlyTagged,Asn1CustomStructure,Asn1EncapsulatingOctetString,Asn1PrimitiveOctetString,Asn1Set,Asn1SetOf,Asn1Primitive, andAsn1OctetStringserialize 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
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.
val value = TutorialDocPerson(name = "A", age = 5)
val der = DER.encodeToByteArray(value)
check(der.toHexString() == "30060c0141020105") /* (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)! */
- 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)! */
- 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)! */
}
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")
}
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 }
}
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)! */
}
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")
- 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.1SETsemantics. - Kotlin
Map<K, V>is encoded as a structured collection of key/value entries.
val value = TutorialDocMapAndSet(
map = mapOf(1 to 2),
set = setOf(3),
)
val der = DER.encodeToByteArray(value)
check(der.toHexString() == /* (1)! */"300d30060201010201023103020103")
- 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" }
- Canonical encoding from rich model (
Asn1Setsorts by DER rules). Explore on asn1js.eu - 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:
RawTaggedPayloadstores the raw implicitly tagged element for byte-exact re-use.ImplicitlyTaggedPayload(value class with@Asn1Tag) provides the schema-level tagging contract.- a
@Transientparsed value is materialized at instantiation time, so structurally invalid raw payloads are rejected immediately. - both
payloadandsignatureinSignedBoxare 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"
Format Options
DER format behaviour can be tuned with the DER builder:
explicitNulls = true: encode nullablenullas ASN.1NULLencodeDefaults = 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)! */
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)! */
- 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)! */
- 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)! */
- 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)! */
- 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)! */
- 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)! */
- 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
- Low-Level ASN.1 API: raw TLV/DER parse and decode utilities.