Choosing x690¶
Python has several libraries for ASN.1 data. They differ in where the structure of a message is defined, what decoding returns, and what a type checker can see. This page decodes the same message with five of them, then explains why x690 works the way it does and where it falls short today.
The task¶
Each example decodes an SNMP GetRequest, reads its request ID and the
first object identifier, and encodes the message back to the same bytes.
This is the message in ASN.1:
Demo DEFINITIONS IMPLICIT TAGS ::= BEGIN
Message ::= SEQUENCE {
version INTEGER,
community OCTET STRING,
data GetRequest
}
GetRequest ::= [0] PDU
PDU ::= SEQUENCE {
request-id INTEGER,
error-status INTEGER,
error-index INTEGER,
variable-bindings SEQUENCE OF VarBind
}
VarBind ::= SEQUENCE {
name OBJECT IDENTIFIER,
value NULL
}
END
All examples read the same bytes from DATA:
DATA = (
b"0'\x02\x01\x01\x04\x06public"
b"\xa0\x1a\x02\x02\x04\xd2\x02\x01\x00"
b"\x02\x01\x000\x0e0\x0c\x06\x08+\x06"
b"\x01\x02\x01\x01\x01\x00\x05\x00"
)
The examples were run with pyasn1 0.6.4, asn1tools 0.169.0, asn1crypto 1.5.1 and pycrate 0.8.1.
pyasn1¶
pyasn1 describes the structure with
Python classes built from NamedTypes and tag objects:
from pyasn1.codec.ber import decoder, encoder
from pyasn1.type import namedtype, tag, univ
class VarBind(univ.Sequence):
componentType = namedtype.NamedTypes(
namedtype.NamedType(
"name", univ.ObjectIdentifier()
),
namedtype.NamedType("value", univ.Null()),
)
class PDU(univ.Sequence):
tagSet = univ.Sequence.tagSet.tagImplicitly(
tag.Tag(
tag.tagClassContext,
tag.tagFormatConstructed,
0,
)
)
componentType = namedtype.NamedTypes(
namedtype.NamedType(
"request-id", univ.Integer()
),
namedtype.NamedType(
"error-status", univ.Integer()
),
namedtype.NamedType(
"error-index", univ.Integer()
),
namedtype.NamedType(
"variable-bindings",
univ.SequenceOf(componentType=VarBind()),
),
)
class Message(univ.Sequence):
componentType = namedtype.NamedTypes(
namedtype.NamedType("version", univ.Integer()),
namedtype.NamedType(
"community", univ.OctetString()
),
namedtype.NamedType("data", PDU()),
)
message, _ = decoder.decode(DATA, asn1Spec=Message())
pdu = message["data"]
int(pdu["request-id"])
# 1234
str(pdu["variable-bindings"][0]["name"])
# '1.3.6.1.2.1.1.1.0'
encoder.encode(message) == DATA
# True
The schema mirrors ASN.1 closely and covers the whole standard, including
BER, CER and DER. The classes are configured through class attributes rather
than written as ordinary Python, and values are pyasn1 objects that convert
with int() or str(). pyasn1 ships no type hints; separate stubs exist
in typeshed (types-pyasn1).
asn1tools¶
asn1tools compiles the ASN.1 text at runtime and works with plain dictionaries:
import asn1tools
spec = asn1tools.compile_string(SPEC, "ber")
message = spec.decode("Message", DATA)
pdu = message["data"]
pdu["request-id"]
# 1234
pdu["variable-bindings"][0]["name"]
# '1.3.6.1.2.1.1.1.0'
spec.encode("Message", message) == DATA
# True
SPEC holds the ASN.1 module above. This is the shortest code, and
asn1tools supports many encodings besides BER: DER, PER, UPER, OER, XER and
JER among them. Types are named by strings, and the result is a dict, so
neither the names nor the values are known to a type checker or an editor.
asn1tools ships no type hints.
asn1crypto¶
asn1crypto describes the structure with Python classes and field lists:
from asn1crypto import core
class VarBind(core.Sequence):
_fields = [
("name", core.ObjectIdentifier),
("value", core.Null),
]
class VarBinds(core.SequenceOf):
_child_spec = VarBind
class PDU(core.Sequence):
_fields = [
("request-id", core.Integer),
("error-status", core.Integer),
("error-index", core.Integer),
("variable-bindings", VarBinds),
]
class Message(core.Sequence):
_fields = [
("version", core.Integer),
("community", core.OctetString),
("data", PDU, {"implicit": 0}),
]
message = Message.load(DATA)
pdu = message["data"]
pdu["request-id"].native
# 1234
pdu["variable-bindings"][0]["name"].native
# '1.3.6.1.2.1.1.1.0'
message.dump() == DATA
# True
The classes are compact, and nested values are parsed only when they are read, as in x690. asn1crypto focuses on cryptography: it ships the structures of X.509, PKCS and related standards, decodes BER and encodes DER. It ships no type hints.
pycrate¶
pycrate compiles the ASN.1 module into a generated Python module:
from pycrate_asn1c.asnproc import (
PycrateGenerator,
compile_text,
generate_modules,
)
compile_text(SPEC)
generate_modules(PycrateGenerator, "demo_gen.py")
import demo_gen
Message = demo_gen.Demo.Message
Message.from_ber(DATA)
pdu = Message.get_val()["data"]
pdu["request-id"]
# 1234
pdu["variable-bindings"][0]["name"]
# (1, 3, 6, 1, 2, 1, 1, 1, 0)
Message.to_ber() == DATA
# True
pycrate handles large real-world specifications and many encodings, including the PER variants used in mobile networks. Decoding fills the generated object in place, values come back as dictionaries and tuples, and the generated code ships no type hints.
x690¶
x690 needs no schema for the types it knows. Only the context-specific
GetRequest needs a class:
from x690 import decode
from x690.types import Sequence
from x690.util import TypeClass
class GetRequest(Sequence):
TYPECLASS = TypeClass.CONTEXT
TAG = 0
message, _ = decode(DATA, enforce_type=Sequence)
pdu = message[2]
assert isinstance(pdu, GetRequest)
pdu[0].value
# 1234
pdu[3].pythonize()[0][0]
# '1.3.6.1.2.1.1.1.0'
bytes(message) == DATA
# True
Values are ordinary Python classes, and the package ships type hints: a type
checker knows that message is a Sequence. The items inside a sequence
are typed as X690Type[Any], so indexing into one needs an isinstance
check first, as for pdu above. Access is by position. x690 encodes BER
only.
Summary¶
Library |
Structure from |
Decoded values |
Type hints |
|---|---|---|---|
pyasn1 |
Python classes |
pyasn1 objects |
typeshed stubs |
asn1tools |
ASN.1 text |
|
none |
asn1crypto |
Python classes |
asn1crypto objects |
none |
pycrate |
generated module |
|
none |
x690 |
Python classes |
x690 objects |
first level |
Performance¶
A benchmark decodes the same synthetic data with all five libraries:
records shaped like telecom charging data records, about 80 bytes each, in
four workloads. stream reads records one after another, full reads
a document of all records by iteration, partial reads only its last
record, and indexed reads every record by index. Times cover decoding
only; memory is the peak on top of the input.
These are the results for 1,000,000 records (80 MB) on a desktop with an AMD Ryzen 5 5600X (6 cores), 31 GB of memory and CPython 3.12.
Note
Absolute times depend on the machine. Run the benchmark on your own hardware before relying on them.
Reading records one at a time keeps every library under 1 MB of extra memory. asn1crypto is fastest there with 6.3 s, followed by x690 with 9.3 s, asn1tools with 9.5 s, pycrate with 39 s and pyasn1 with 104 s.
For a whole document, x690 is the fastest and needs a few megabytes where the others need between 351 MB and 9.6 GB: it keeps only the start of each item and decodes items as they are read.
Library |
full |
partial |
indexed |
|---|---|---|---|
x690 |
10 s |
0.7 s |
11 s |
asn1crypto |
13 s |
1.2 s |
13 s |
asn1tools |
13 s |
13 s |
13 s |
pycrate |
70 s |
70 s |
71 s |
pyasn1 |
146 s |
144 s |
145 s |
Library |
full |
partial |
indexed |
|---|---|---|---|
x690 |
8 MB |
8 MB |
8 MB |
asn1crypto |
1,957 MB |
351 MB |
1,957 MB |
asn1tools |
1,436 MB |
1,437 MB |
1,437 MB |
pycrate |
5,494 MB |
5,494 MB |
5,494 MB |
pyasn1 |
9,569 MB |
9,569 MB |
9,569 MB |
The same benchmark on a small machine, with 2 cores and 3.7 GB of memory and each case limited to 2 GB, ran about half as fast and kept the same order. pycrate and pyasn1 could not decode the whole document there: they ran out of memory.
The data uses universal tags only, so that all libraries decode the same
structure; real charging records use context-specific tags (#6). The benchmark runs with one
command and writes a JSON report with the machine details. The reports
behind these numbers are in benchmarks/results.
Why x690 works this way¶
x690 started as the encoding layer of puresnmp. The libraries above either asked for a schema written in their own configuration style, or returned untyped dictionaries. x690 set out to be plain Python, typed, and free of build steps.
- Python classes, schema optional
A type is a class with three attributes. Universal types decode without any declaration, and a protocol type needs only its class, nature and tag. There is no ASN.1 compiler yet; generating x690 classes from ASN.1 modules is a possible addition, as long as x690 keeps working without it.
- Typed values, first level
decode()returns the type passed asenforce_typeand checks it at runtime. Typing nested values as well would push a schema onto every user, so x690 stops at the first level for now: nested values are narrowed withisinstancewhere the code needs their type.- No copies, parse on read
Before version 0.5, every nested value copied its part of the data. On large documents this cost memory and time. x690 now keeps one reference to the input and parses a value only when it is read. A sequence records where its items start and creates each item when it is accessed. Beyond the input itself, memory grows by about 8 bytes per item of the sequence being read, plus the values the application keeps. Applications that need a few values from a large document do no work for the rest. Errors in a value surface when that value is read.
- Decode anything
Tags x690 does not know decode to
UnknownTypeinstead of failing, andpretty()shows their class, nature and tag. This makes x690 a tool for reverse engineering undocumented data. It also means x690 does not validate a structure.- Pure Python
x690 has no compiled parts and one small dependency. There are no wheels to build, it runs wherever Python runs, and the code is short enough to audit. Raw throughput is not its goal: on a stream of small records, asn1crypto decodes faster. For whole documents, x690 is the fastest of the libraries compared, because it does less work.
- BER
x690 implements the Basic Encoding Rules, which SNMP uses. Its output has not been checked against the additional rules of DER.
Current limitations¶
Warning
Context-specific tags are registered globally, not per enclosing type.
Protocols that reuse a tag such as [0] in different structures,
X.509 and LDAP among them, cannot be modelled yet. Fixing this has high
priority (#6).
Several universal types hold raw bytes instead of a Python value; see Supported types (#7).
Sequences are accessed by position, not by name (#9).
There is no validating decode that rejects unknown types (#8).
There is no code generator for ASN.1 modules (#10).
DER is not a mode of its own (#11).
When to choose x690¶
x690 fits when the data is BER, the protocol uses universal types and protocol types with unique tags (SNMP is the reference case), and the application decodes large documents with limited memory, reads only parts of them, wants typed Python objects, or inspects data without a specification. For X.509 and other cryptographic structures, asn1crypto ships the definitions ready to use. For complete ASN.1 modules or encodings other than BER, asn1tools and pycrate compile the specification directly.