Hardening, Fuzzing & Robustness
awesn1 has a parser at its core. Parsers parse data from untrusted (attacker-controlled) input. Hence, the awesn1 raw parser, encoder, and
renderers are designed to fail predictably on adversarial input: a malformed, oversized, or deeply-nested blob
yields a bounded Asn1Exception/SerializationException or a bounded result — never a StackOverflowError, runaway
recursion, an uncatchable crash, or silent corruption.
This page collects the robustness model in one place. For the APIs it refers to, see the Low-Level ASN.1 API and the Serialization pages.
Hardening is an ongoing effort — and never truly done
Hardening a parser of attacker-controlled input is a moving target: Treat the guarantees on this page as a living baseline, not a finished checklist. We keep extending the fuzzing and coverage behind them, improve things and fix issues as we discover them.
If you find a gap, please report it confidentially. Follow our security policy: Do not open a public issue if you discover a potential security vulnerability. Use GitHub's private vulnerability reporting instead. We will acknowledge it promptly and do our best to push out a fix as soon as possible.
Recap: Deferred Semantic Parsing
awesn1 separates raw DER parsing from ASN.1 meaning (see Deferred Semantic Parsing):
- The raw parser enforces only what keeps the tree well-bounded and DER-shaped: it rejects indefinite-length
encoding, non-minimal long-form lengths, length overflow, children that overrun their parent, high-tag-number form
for tags that belong in low-tag form (
≤ 30), and universal tags0(end-of-contents) and15. - Semantic validation is deferred to typed decoders (
decodeToBoolean,Asn1Integer.decodeFromAsn1ContentBytes, string/time/OID decoders, …). This is deliberate: it lets you parse quirky real-world input into a raw tree, preserve exact bytes for diagnostics or signature verification, and then decide how strict to be.
Deferred semantics is a design decision, not a gap — but every semantic decoder is still held to the robustness guarantees below (no leak, no amplification, no overflow).
Hardening Scope
This section describes which parts of awesn1 are hardened against hostile input and to what extent.
Purely Iterative Parsing and Encoding (New since 0.5.0)
Parsing, encoding (derEncoded/encodeTo), prettyPrint/toString, equals/hashCode, and SET ordering are all
iterative, using explicit work-stacks instead of the call stack. OID decoding/encoding and
big-integer arc arithmetics are loop-based as well. Arbitrarily deep nesting — SEQUENCE in SEQUENCE, EXPLICIT tags,
custom constructed structures, encapsulating OCTET STRINGs — cannot exhaust the stack. Encapsulating octet strings are
decapsulated iteratively and without per-layer byte copies, so nested octet string content stays linear in input size.
Bounded Lengths and Overflow Guards
- Every element exposes
contentLength: Int/overallLength: Int, guarded: an aggregate size exceedingInt.MAX_VALUE(~2 GiB) throwsAsn1Exceptionrather than silently wrapping. The…Longaccessors andencodeTo(sink)exist for callers who deliberately work past 2 GiB and/or require streaming output.
Do note that some buffering still occurs, but it will only ever be linear. - A single primitive's content is
ByteArray-backed, so it is hard-capped atInt.MAX_VALUEbytes; a longer primitive is rejected, not truncated. - All length sums go through
plusExact, and every attacker-influencedLong→Intnarrowing goes throughtoNonnegativeIntChecked— so a size computation can never quietly turn a bounds check into a bypass.
By default everything stays Int-based: DerConfiguration.maxInputLength defaults to Int.MAX_VALUE, so any input
decoded with the defaults produces only Int-sized lengths and never trips the guards — the …Long accessors and
encodeTo(sink) are only relevant once you deliberately raise that limit to work with multi-gigabyte structures.
Catchable Errors Only
Every failure on hostile input surfaces as a catchable Asn1Exception (core), SerializationException (kxs), NumberFormatException, IllegalArgumentException, etc.
but never a fatal VM error, unless you forget to bound custom code, or you raise the built-in limits, in which case you can
still exhaust memory or the call stack.
Decode/parse paths run inside runRethrowing/runWrappingAs, which catch non-fatal Throwables and wrap them; only VM-fatal errors are rethrown.
Bounded Depth and Collection Sizes
kxsshares aDerDepthGuardbetweenDerDecoder/DerEncoderthat throwsSerializationExceptionpastDerConfiguration.maxNestingDepth(default 32, maximum 65,536). Built-in ASN.1 element trees are checked iteratively on both encode and decode. Raising the limit is supported for runtimes provisioned with a matching stack; choosing a value beyond the available stack will still cause an SoE. Adjusting this value based on platform stack limits remains the caller's responsibility.- A
MAX_COLLECTION_SIZEguard (Int.MAX_VALUE - 8, checked per append) turns an overfull child list into a catchableAsn1Exception. This is an addressability backstop, not a heap-DoS defense — see input bounding below.
Bounded Rendering
Asn1Element.toString() and prettyPrint() are length-capped (JVM String/StringBuilder are themselves Int-bounded) and
truncate with a … (output truncated) marker rather than exhausting memory; an oversized primitive's content is
shown as a bounded hex prefix with a …(N bytes) note. These renderers are for human inspection — use derEncoded for
the exact bytes.
Bounded Decimal Conversion (INTEGER decimal, OID nodes)
Converting a magnitude between base-256 and base-10 is inherently O(n²) in the byte length.
A single small attacker-supplied field — a large-magnitude INTEGER, or an OBJECT IDENTIFIER with one giant base-128
sub-identifier — is cheap to parse (decoding is linear) but could otherwise burn minutes of CPU the moment it is
converted, compared, rendered, or merely logged. awesn1 bounds every one of these paths:
- Fixed-width decoders are O(1).
Asn1Integer.toInt()/toIntOrNull()/toLong()/toLongOrNull()(and theInt/Long/UInt/ULongdecodeFromAsn1ContentByteshelpers) range-check the magnitude byte length before any conversion, so reading a "small int" version/serial/counter field out of a hostile 64 KiB magnitude returnsnull/throws immediately instead of running the full expansion. - The decimal conversion is chunked. The base-256 ↔ base-10 routine works in base-10⁹ limbs with primitive
arithmetic rather than a per-decimal-digit
List<Char>schoolbook. This is still O(n²) but with a ~2-orders-of-magnitude smaller constant, so realistic crypto integers (RSA-8192 = 1 KiB, RSA-32768 = 4 KiB, DH-8192, Paillier/Damgård–Jurik ciphertexts) convert in single-digit milliseconds. - A magnitude cap bounds the worst case.
toDecimalString()caps the magnitude byte length it will process (default 32 KiB, covering realistic crypto integers with wide margin) and throws above the cap.Asn1Integer.toString()instead renders hexadecimal, prefixes truncated output with[truncated, N bytes total], renders at most 48 magnitude bytes, and never throws. FiniteAsn1Real.toString()applies the same bound to its hexadecimal mantissa.toHexString()and the default non-DER serializers remain exact rather than truncating. - Decimal serializers are lossless-or-throw, never truncate. The opt-in
Asn1IntegerDecimalStringSerializeremits the exact value or throwsAsn1Exceptionover the cap.ObjectIdentifierStringSerializer, which remains decimal, has the same lossless-or-throw contract. Deserializing an over-limit decimal INTEGER or an OID string with an oversized arc throws at the cap rather than running the quadratic parse.
DER is never affected
These caps concern only explicit decimal conversion and decimal OID handling. DER TLV encoding and decoding operate on
two's-complement/base-128 bytes and never route through the base-10 conversion, so encoding to DER and parsing
from DER are unaffected by MAX_MAGNITUDE_BYTES / MAX_SUBIDENTIFIER_BYTES — round-tripping a value through DER is
always exact regardless of the caps.
No Reliance on Implicit Array Bounds Checks (Kotlin/Wasm)
On JVM/Native, an out-of-bounds array access throws a catchable exception; on Kotlin/Wasm it traps instead. awesn1
therefore does not depend on the runtime to throw on out-of-range indexing during parse and encode.
Hot paths bounds-check explicitly (e.g. BitSet.getBit, the kxs enum-ordinal mapping, and DerDecoder element access),
s.t. that the behaviour is identical on all targers and errors stay catchable.
Fail-Fast Ambiguity Detection (kxs)
kxs rejects ambiguous DER layouts at encode time rather than emitting bytes that decode differently elsewhere
(adjacent optional fields sharing a leading tag, undecidable null-omission, ambiguous inline-class tagging). There is no
ignoreUnknownElements: trailing or unexpected elements are rejected. See Deep Dive: Disambiguation.
Fuzzing and Regression Coverage
The robustness guarantees are backed by an extensive test surface containing targeted hostile inputs and the OpenSSL fuzz corpora run through a third-party classifier/oracle, whose behaviour is then compared with awesn1's. The test harness includes:
- Structural limits: deep structural nesting (tens of thousands of levels), deep OCTET STRING decapsulation,
length parsing/overflow guards, parent/child length-consistency enforcement, tag parsing edge cases, and bounded
Sourceenforcement. - Numeric edge cases: INTEGER minimality and two's-complement conversion (incl. large negatives and large
varint-backed magnitudes), OID arc decoding,
BitSetand BIT STRING edge cases. - Signed/Unsigned integer to primitive mapping for
kxs, ensuring that numbers round-trip byte-for-byte. - Differential / corpus fuzzing: randomized and corpus-driven inputs (including a real-world certificate corpus) parsed and round-tripped, asserting that malformed input fails with a bounded exception and that valid input round-trips byte-for-byte.
- A humungous unit test suite using data-driven testing and property testing, with actual test data series in the millions, used to ensure correct round-tripping and accurate semantics based comparing with outside, known-good implementations.
Input-Bounding as the Caller's Responsibility
Always Bound Untrusted Input
awesn1 parses into an in-memory tree, so input size directly bounds memory. The kxs default only prevents
exceeding the target's ByteArray addressability; wherever denial-of-service is a concern, lower that cap to the
largest payload your protocol actually permits:
ByteArrayparsing is bounded by the array's size — sanity-check that size before parsing untrusted data. Even a small blob of tiny nested elements can allocate a large object graph (but never causes a stack overflow).Source(streaming) parsing is always bounded bymaxInputLength; its optional per-call bytelimitcan tighten that ceiling. The same holds forSource.decodeAsn1VarBigInt(limit): a big varint has no inherent size ceiling, unlikedecodeAsn1VarUInt/decodeAsn1VarULong, which their target type caps at 5 resp. 10 total bytes.- Via
kotlinx.serialization, the cap isDER { maxInputLength = … }(default: targetByteArrayceiling); lower it for untrusted decode. - Under non-DER formats (JSON, CBOR, …), bound untrusted input through that format or before invoking it.
awesn1's fallback serializers receive values only after the format has materialised them, so a second generic
length check there would be too late to protect memory and would impose an arbitrary application policy.
The decimal INTEGER fallback is the exception: it retains explicit conversion limits because its radix
conversion is quadratic. Malformed fallback values surface as
SerializationException.
It is a deliberate decision to leave input bounding to the caller. Only the caller knows realistic, expected input's sizes and semantics. Take X.509 certificates as an example: ECDSA-signed certificates are usually small, issuer DNs are also usually bounded, but PQC-signed certificates may even exceed certificates signed with long RSA keys. Also, when parsing CMS or CRLs, the expected input sizes are orders of magnitude larger compared to parsing certificates.
Hence, callers must bound the input size themselves!
Memory amplification
Parsed elements require more memory than their wire representation, especially when input contains many tiny nested elements. The iterative parser prevents stack overflow, but callers must still choose input limits appropriate for their workload.
Constructing Huge Data Programmatically
These guarantees concern parsing untrusted input. If you build an Asn1Element tree in code whose aggregate size
exceeds Int.MAX_VALUE, that is under your control: read the …Long accessors and encode via encodeTo(sink) to a
streaming sink instead of materializing derEncoded (a single ByteArray, itself capped at ~2 GiB).
Residual Risks
What cannot be hardened due to kotlinx.serialization intrinsics:
The kxs module implements a kotlinx.serialization format, which means that some rather intricate details
cannot be fully controlled by awesn1 during deserialization:
Custom decoder recursion is not bounded
maxNestingDepth does not apply to recursion performed inside custom KSerializer code or
Asn1Serializable.doDecode. Those functions are trusted application code: awesn1 receives control only before
and after the call, so it cannot observe or count recursive calls made inside it. Such implementations must
enforce their own depth and allocation limits.
- Pre-parsed
Asn1Elementtrees bypassmaxInputLength.maxInputLengthand the streaming bytelimitbound the parse step. If you parse a tree beforehand and feed the already-materializedAsn1ElementintoDER.decodeFromTlv, the allocation already happened — the cap can no longer protect you. Bound the bytes before you build the tree.DerConfiguration.maxNestingDepthstill applies. - Custom
KSerializers drive the codec directly. A hand-written serializer has full control over the sequence ofencode*/decode*/beginStructurecalls.kxsdetects and rejects recognizable ambiguity at encode time, but it cannot stop a custom serializer from emitting structurally valid yet semantically wrong (or non-canonical) DER, or from driving the decoder in an order its descriptor does not describe. Custom serializers are trusted code — review them as such. See Custom Serializers Re-Introducing Ambiguity. - The
@Serializabledescriptor is the only schema source.kotlinx.serializationexposes a structural descriptor, not your runtime invariants.kxscannot enforce constraints the descriptor cannot express — value ranges beyond the Kotlin type, cross-field relationships, or "thisIntis really a constrained enumerated" — so validate those in your own code after decoding (preferably inside theinitblock of the serializable type), or implement custom serializers on top. - Inline value classes are flattened by the framework.
kotlinx.serializationerases single-field inline (value) classes to their backing value beforekxssees them. Placing an ASN.1 tag on an inline class's backing property is therefore ambiguous;kxsfails fast with aSerializationExceptionrather than guessing, but it cannot make that combination work. Instead, annotate the inline class directly and not the backing property. See Inline/Value Classes. - Absent vs. null vs. default is not always expressible. The framework's
decodeElementIndexprotocol cannot, on its own, distinguish an omitted optional field from an explicit null or a default for adjacent fields that share a leading tag.kxsmakes this decidable by rejecting undecidable layouts at encode time and requiring explicit disambiguation (implicit/EXPLICIT tags or leading-tag metadata), but it cannot retrofit a fundamentally ambiguous schema — you must disambiguate it. See Deep Dive: Disambiguation.
See also
- Low-Level ASN.1 API and its Performance numbers
- Serialization → Deep Dive: Disambiguation