Skip to content

Conversation

@facutuesca
Copy link
Contributor

Part of #12283

Overview

This PR adds the initial structure for the ASN.1 module, plus support for defining SEQUENCE types and using INTEGER fields. Only the encoding logic is included, to keep the PR size small and to keep the focus on the structure of the code.

Here's an example of what using the API looks like:

import cryptography.hazmat.asn1 as asn1

@asn1.sequence
class Example:
    foo: int
    bar: int

value = Example(foo=9, bar=6)

encoded = asn1.encode_der(value)
assert encoded == b"\x30\x06\x02\x01\x09\x02\x01\x06"

Structure

The code is divided into a hazmat.asn1 Python module, and a Rust _rust.asn1_exp Rust module. This last one has a different name in order to avoid mixing it with the existing asn1 Rust module (which can be done later, but for now doing it like this simplifies the PR).

Details

Taking as an example the code snippet above, this is what happens:

  1. While interpreting the class definition, the @asn1.sequence decorator is executed, which goes through the Example class and recursively reads its type annotations. This information is converted to a Rust object (AnnotatedType) and stored in the Example.__asn1_root__ and Example.__asn1_fields__ attributes. All of this happens in hazmat/asn1/asn1.py
  2. An Example object is instantiated with some values, and asn1.encode_der is called on it. asn1.encode_der is a Rust function defined in asn1_exp/asn1.rs.
  3. This function combines the type information (AnnotatedType object) and the value of the object into an AnnotatedTypeObject value, and encodes it using rust-asn1's asn1::write API.
  4. This is possible because AnnotatedTypeObject implements the asn1::Asn1Writable trait. This is done in asn1_exp/encoding.rs
  5. And that's it, the resulting bytes are now available in Python

Some things (like the empty Annotation struct) are there as placeholders for missing features I removed to keep this PR small. I removed their contents but left the empty struct so that the rest of the data and logic that depend on it will keep the same structure once we re-add the missing features.

@facutuesca facutuesca marked this pull request as draft August 20, 2025 00:26
@facutuesca facutuesca force-pushed the ft/asn1-api-init branch 3 times, most recently from 077a337 to df5289a Compare August 20, 2025 01:07
@facutuesca
Copy link
Contributor Author

facutuesca commented Aug 20, 2025

I added typing-extensions as an unconditional dependency, but it can probably be removed for newer Python versions if we use typing when we detect a new-enough Python version

@facutuesca facutuesca marked this pull request as ready for review August 20, 2025 01:21
@alex
Copy link
Member

alex commented Aug 20, 2025

looks like there's some missed coverage here -- do you want to take a look at the missing test cases before I review?


import typing_extensions as te

from cryptography.hazmat.bindings._rust import asn1_exp
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love this module name, I'm not sure what exp even stands for :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah lol, experimental, it was just meant to have a different name from the existing asn1 module

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably need to find a real name 😂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to suggestions. asn1_api maybe?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest declarative_asn1 for now since it provides the declarative ASN.1 API (or decl_asn1 if we need it to be short)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets use the full word, bytes are free

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines 8 to 12
from typing import (
Any,
ClassVar,
TypeVar,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, house style is to reference these as typing. for clarity (Any is particularly unclear without the typing.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

) -> asn1_exp.AnnotatedType:
annotation = asn1_exp.Annotation()

if field_type == builtins.int:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer is when comparing against types

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


def _register_asn1_type(cls: type[U], root_type: asn1_exp.RootType) -> None:
raw_fields = te.get_type_hints(cls, include_extras=True)
setattr(cls, "__asn1_fields__", _annotate_fields(raw_fields))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly: cls.__asn1_fields__ = _annotate_fields(raw_fields)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mypy complains:

src/cryptography/hazmat/asn1/asn1.py:55: error: "type[U]" has no attribute "__asn1_fields__"  [attr-defined]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this error doesn't make much sense to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need both __asn1_fields__ and __asn1_root__? It seems like only one should be required.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, see below, I removed __asn1_fields__

@facutuesca
Copy link
Contributor Author

looks like there's some missed coverage here -- do you want to take a look at the missing test cases before I review?

Missing tests added!

@alex
Copy link
Member

alex commented Aug 21, 2025

Will review again tomorrow. I should have said at the top: this looks like a solid start!


def _register_asn1_type(cls: type[U], root_type: asn1_exp.RootType) -> None:
raw_fields = te.get_type_hints(cls, include_extras=True)
setattr(cls, "__asn1_fields__", _annotate_fields(raw_fields))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this error doesn't make much sense to me.


def _register_asn1_type(cls: type[U], root_type: asn1_exp.RootType) -> None:
raw_fields = te.get_type_hints(cls, include_extras=True)
setattr(cls, "__asn1_fields__", _annotate_fields(raw_fields))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need both __asn1_fields__ and __asn1_root__? It seems like only one should be required.

// 2.0, and the BSD License. See the LICENSE file in the root of this repository
// for complete details.

use pyo3::prelude::*;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use import *

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


use crate::asn1_exp::types::{AnnotatedType, AnnotatedTypeObject, Type};

fn write_value<T: SimpleAsn1Writable>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to do very much?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, it's because it's useful to avoid duplication only when all the other types are implemented. See here (it saves us writing that match statement once for each type)

Comment on lines 30 to 33
Type::Sequence(cls) => {
let fields = cls
.getattr(py, "__asn1_fields__")
.map_err(|_| asn1::WriteError::AllocationError)?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like instead of a separate __asn1_fields__ we could just store the field types in Type::Sequence()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, good idea. Now Type::Sequence contains the root type and the fields.

foo: Invalid


class TestEncoding:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put tests of specific types in their own test file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

# mypy
# virtualenv
typing-extensions==4.14.1 ; python_full_version >= '3.9'
typing-extensions==4.13.2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should be built in a way that uses the highest version for each python version:

uv pip compile -U --universal --python-version 3.8 --all-extras --unsafe-package=cffi --unsafe-package=pycparser --unsafe-package=setuptools --unsafe-package=cryptography-vectors --unsafe-package=bcrypt pyproject.toml -o ci-constraints-requirements.txt -q is the right way to build it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

pyproject.toml Outdated

# Must be kept in sync with `project.dependencies`
"cffi>=1.14; platform_python_implementation != 'PyPy'",
"typing-extensions>=4.13.2",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed at build time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not, ha. Fixed

@alex
Copy link
Member

alex commented Aug 24, 2025

Looks like there's some merge conflicts

@facutuesca
Copy link
Contributor Author

Looks like there's some merge conflicts

fixed!

Copy link
Member

@alex alex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small comments, but otherwise this LGTM. I'd like @reaperhulk to have a look before we merge though.


setattr(cls, "__asn1_root__", root)

def new_init(self: U, /, **kwargs: object) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably codegen this for performance, but doesn't have to happen in this PR. Can you add a TODO though?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a TODO comment

import cryptography.hazmat.asn1 as asn1


class TestEncoding:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One class per type we're testing teh encoding of (sequence vs. int so far)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

pyproject.toml Outdated
# Must be kept in sync with `build-system.requires`
"cffi>=1.14; platform_python_implementation != 'PyPy'",
# Must be kept in sync with ./.github/requirements/build-requirements.{in,txt}
"typing-extensions>=4.13.2",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required because we need two functions that were added in Python 3.11 right? is it a requirement for >= 3.11?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's not, let's use the python_version branch

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like typing has this in 3.11+ (and Facundo did note that in a comment 5 days ago, but there's a lot of traffic on this PR 😄). Let's call it from typing where possible and set the python_version on the rest, so we know when we can remove this...in 4 years.

Copy link
Contributor Author

@facutuesca facutuesca Aug 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed! there was an extra typing function which we need to use typing-extensions for: typing.get_type_hints which we call with its include_extras parameter. That parameter was added in Python 3.9, so I also added it to the conditional.

Now it will use the typing-extensions variant for any Python < 3.11. I can make it even more precise (only use it for Python < 3.9), but for now I only left a comment saying to remove it when the min version is 3.9.

if sys.version_info < (3, 11):
    import typing_extensions

    # We use the `include_extras` parameter of `get_type_hints`, which was
    #  added in Python 3.9, so this can be replaced by the `typing` version
    # once the min version is >= 3.9
    get_type_hints = typing_extensions.get_type_hints
else:
    get_type_hints = typing.get_type_hints

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason not to make this branch on 3.9? Seems cleaner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, fixed

Copy link
Member

@alex alex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A small handful of comments, but otherwise LGTM!


@typing_extensions.dataclass_transform(kw_only_default=True)
def sequence(cls: type[U]) -> type[U]:
dataclass_cls = dataclasses.dataclass(cls)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want all the defaults that dataclass has, I think to start with the only thing we want to define is init, everything else should be false.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. I had to add another Python version check, since one of the dataclass arguments (match_args) that defaults to True was added in Python 3.10.

#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.asn1")]
#[derive(Debug)]
pub struct AnnotatedType {
#[pyo3(get)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason to expose these?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed it for now, it will be needed in the future

#[pyo3::pymethods]
impl Annotation {
#[new]
#[pyo3(signature = ())]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

@facutuesca facutuesca force-pushed the ft/asn1-api-init branch 4 times, most recently from 0e3bcc0 to aa6d03e Compare August 27, 2025 11:55
@facutuesca facutuesca force-pushed the ft/asn1-api-init branch 5 times, most recently from dd06339 to 00cacb8 Compare August 30, 2025 14:02
alex
alex previously approved these changes Aug 30, 2025
Copy link
Member

@alex alex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will wait for @reaperhulk to confirm he's goood, but I think this is ready

reaperhulk
reaperhulk previously approved these changes Sep 13, 2025
@reaperhulk
Copy link
Member

This needs a rebase now, sorry!

Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
Signed-off-by: Facundo Tuesca <[email protected]>
@facutuesca facutuesca dismissed stale reviews from reaperhulk and alex via 871834e September 13, 2025 09:30
@reaperhulk reaperhulk merged commit 0fb8f93 into pyca:main Sep 13, 2025
132 checks passed
@facutuesca facutuesca deleted the ft/asn1-api-init branch September 13, 2025 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants