Skip to content

Commit 0fb8f93

Browse files
authored
Initial implementation of ASN.1 API (#13325)
* Add initial ASN.1 Rust module Signed-off-by: Facundo Tuesca <[email protected]> * Add hazmat.asn1 Python module Signed-off-by: Facundo Tuesca <[email protected]> * Add initial ASN.1 tests Signed-off-by: Facundo Tuesca <[email protected]> * Add typing-extensions dependency Signed-off-by: Facundo Tuesca <[email protected]> * Remove unused protocol Signed-off-by: Facundo Tuesca <[email protected]> * Add tests for missing coverage Signed-off-by: Facundo Tuesca <[email protected]> * Remove upper version bound for typing-extensions Signed-off-by: Facundo Tuesca <[email protected]> * Remove import aliases Signed-off-by: Facundo Tuesca <[email protected]> * Compare types with `is` Signed-off-by: Facundo Tuesca <[email protected]> * Remove unnecessary getattr Signed-off-by: Facundo Tuesca <[email protected]> * Remove unused RootType Signed-off-by: Facundo Tuesca <[email protected]> * Remove more import aliases Signed-off-by: Facundo Tuesca <[email protected]> * Add test for missing coverage Signed-off-by: Facundo Tuesca <[email protected]> * Remove unneeded build dependency Signed-off-by: Facundo Tuesca <[email protected]> * Regenerate ci constraints requirements Signed-off-by: Facundo Tuesca <[email protected]> * Deduplicate conversion of builtin Python types Signed-off-by: Facundo Tuesca <[email protected]> * Tidy up Rust imports Signed-off-by: Facundo Tuesca <[email protected]> * Separate tests into files per test type Signed-off-by: Facundo Tuesca <[email protected]> * Remove unneeded __asn1_fields__ attribute Signed-off-by: Facundo Tuesca <[email protected]> * Re-add typing-extensions to build-requirements.in Signed-off-by: Facundo Tuesca <[email protected]> * Split tests by ASN.1 type Signed-off-by: Facundo Tuesca <[email protected]> * Add TODO Signed-off-by: Facundo Tuesca <[email protected]> * Rename Rust module to `declarative_asn1` Signed-off-by: Facundo Tuesca <[email protected]> * Use typing-extensions only for Python < 3.11 Signed-off-by: Facundo Tuesca <[email protected]> * Use `typing_extensions.get_type_hints` only when <3.9 Signed-off-by: Facundo Tuesca <[email protected]> * Add comment explaining mypy workaround Signed-off-by: Facundo Tuesca <[email protected]> * Use `dataclasses.dataclass` to generate init method Signed-off-by: Facundo Tuesca <[email protected]> * Remove extra dataclass behavior Signed-off-by: Facundo Tuesca <[email protected]> * Remove unused pyo3 annotations Signed-off-by: Facundo Tuesca <[email protected]> * Use `kw_only` with dataclasses Signed-off-by: Facundo Tuesca <[email protected]> * Add test for kw-only initialization Signed-off-by: Facundo Tuesca <[email protected]> --------- Signed-off-by: Facundo Tuesca <[email protected]>
1 parent 1a103ac commit 0fb8f93

File tree

14 files changed

+535
-0
lines changed

14 files changed

+535
-0
lines changed

.github/requirements/build-requirements.in

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ maturin>=1,<2
77
# Must be kept sync with build-system.requires at vectors/pyproject.toml
88
uv_build>=0.7.19,<0.9.0
99

10+
# Must be kept in sync with project.dependencies at pyproject.toml
11+
typing-extensions>=4.13.2; python_version < '3.11'
12+
1013
# WARN: changing the requirements here DOES NOT update the dependencies used
1114
# for building at the github workflow -- it uses build-requirements.txt
1215
# To update build-requirements.txt, update this file and then run

.github/requirements/build-requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ tomli==2.2.1 ; python_full_version < '3.11' \
144144
--hash=sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a \
145145
--hash=sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7
146146
# via maturin
147+
typing-extensions==4.15.0 ; python_full_version < '3.11' \
148+
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
149+
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
150+
# via -r build-requirements.in
147151
uv-build==0.8.17 \
148152
--hash=sha256:061f0406751244f6554fec98e78128af508984b89aef312997e3c1bedd3dd9b0 \
149153
--hash=sha256:2a904939adf3e7eb115a074064dd77a3d2822a0b10621019712109e4a06e4250 \

ci-constraints-requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,13 @@ tomli==2.2.1 ; python_full_version <= '3.11'
257257
# sphinx
258258
typing-extensions==4.13.2 ; python_full_version < '3.9'
259259
# via
260+
# cryptography (pyproject.toml)
260261
# exceptiongroup
261262
# mypy
262263
# virtualenv
263264
typing-extensions==4.15.0 ; python_full_version >= '3.9'
264265
# via
266+
# cryptography (pyproject.toml)
265267
# exceptiongroup
266268
# mypy
267269
# virtualenv

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ dependencies = [
5353
# Must be kept in sync with `build-system.requires`
5454
"cffi>=1.14; platform_python_implementation != 'PyPy' and python_version < '3.14'",
5555
"cffi>=2.0.0; platform_python_implementation != 'PyPy' and python_version >= '3.14'",
56+
# Must be kept in sync with ./.github/requirements/build-requirements.{in,txt}
57+
"typing-extensions>=4.13.2; python_version < '3.11'",
5658
]
5759

5860
[project.urls]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# This file is dual licensed under the terms of the Apache License, Version
2+
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
# for complete details.
4+
5+
from cryptography.hazmat.asn1.asn1 import encode_der, sequence
6+
7+
__all__ = [
8+
"encode_der",
9+
"sequence",
10+
]
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# This file is dual licensed under the terms of the Apache License, Version
2+
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
# for complete details.
4+
5+
from __future__ import annotations
6+
7+
import dataclasses
8+
import sys
9+
import typing
10+
11+
if sys.version_info < (3, 11):
12+
import typing_extensions
13+
14+
# We use the `include_extras` parameter of `get_type_hints`, which was
15+
# added in Python 3.9. This can be replaced by the `typing` version
16+
# once the min version is >= 3.9
17+
if sys.version_info < (3, 9):
18+
get_type_hints = typing_extensions.get_type_hints
19+
else:
20+
get_type_hints = typing.get_type_hints
21+
else:
22+
get_type_hints = typing.get_type_hints
23+
24+
from cryptography.hazmat.bindings._rust import declarative_asn1
25+
26+
T = typing.TypeVar("T", covariant=True)
27+
U = typing.TypeVar("U")
28+
29+
30+
encode_der = declarative_asn1.encode_der
31+
32+
33+
def _normalize_field_type(
34+
field_type: typing.Any, field_name: str
35+
) -> declarative_asn1.AnnotatedType:
36+
annotation = declarative_asn1.Annotation()
37+
38+
if hasattr(field_type, "__asn1_root__"):
39+
annotated_root = field_type.__asn1_root__
40+
if not isinstance(annotated_root, declarative_asn1.AnnotatedType):
41+
raise TypeError(f"unsupported root type: {annotated_root}")
42+
return annotated_root
43+
else:
44+
rust_field_type = declarative_asn1.non_root_python_to_rust(field_type)
45+
46+
return declarative_asn1.AnnotatedType(rust_field_type, annotation)
47+
48+
49+
def _annotate_fields(
50+
raw_fields: dict[str, type],
51+
) -> dict[str, declarative_asn1.AnnotatedType]:
52+
fields = {}
53+
for field_name, field_type in raw_fields.items():
54+
# Recursively normalize the field type into something that the
55+
# Rust code can understand.
56+
annotated_field_type = _normalize_field_type(field_type, field_name)
57+
fields[field_name] = annotated_field_type
58+
59+
return fields
60+
61+
62+
def _register_asn1_sequence(cls: type[U]) -> None:
63+
raw_fields = get_type_hints(cls, include_extras=True)
64+
root = declarative_asn1.AnnotatedType(
65+
declarative_asn1.Type.Sequence(cls, _annotate_fields(raw_fields)),
66+
declarative_asn1.Annotation(),
67+
)
68+
69+
setattr(cls, "__asn1_root__", root)
70+
71+
72+
# Due to https://github.com/python/mypy/issues/19731, we can't define an alias
73+
# for `dataclass_transform` that conditionally points to `typing` or
74+
# `typing_extensions` depending on the Python version (like we do for
75+
# `get_type_hints`).
76+
# We work around it by making the whole decorated class conditional on the
77+
# Python version.
78+
if sys.version_info < (3, 11):
79+
80+
@typing_extensions.dataclass_transform(kw_only_default=True)
81+
def sequence(cls: type[U]) -> type[U]:
82+
# We use `dataclasses.dataclass` to add an __init__ method
83+
# to the class with keyword-only parameters.
84+
if sys.version_info >= (3, 10):
85+
dataclass_cls = dataclasses.dataclass(
86+
repr=False,
87+
eq=False,
88+
# `match_args` was added in Python 3.10 and defaults
89+
# to True
90+
match_args=False,
91+
# `kw_only` was added in Python 3.10 and defaults to
92+
# False
93+
kw_only=True,
94+
)(cls)
95+
else:
96+
dataclass_cls = dataclasses.dataclass(
97+
repr=False,
98+
eq=False,
99+
)(cls)
100+
_register_asn1_sequence(dataclass_cls)
101+
return dataclass_cls
102+
103+
else:
104+
105+
@typing.dataclass_transform(kw_only_default=True)
106+
def sequence(cls: type[U]) -> type[U]:
107+
# Only add an __init__ method, with keyword-only
108+
# parameters.
109+
dataclass_cls = dataclasses.dataclass(
110+
repr=False,
111+
eq=False,
112+
match_args=False,
113+
kw_only=True,
114+
)(cls)
115+
_register_asn1_sequence(dataclass_cls)
116+
return dataclass_cls
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# This file is dual licensed under the terms of the Apache License, Version
2+
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
# for complete details.
4+
import typing
5+
6+
def encode_der(value: typing.Any) -> bytes: ...
7+
def non_root_python_to_rust(cls: type) -> Type: ...
8+
9+
# Type is a Rust enum with tuple variants. For now, we express the type
10+
# annotations like this:
11+
class Type:
12+
Sequence: typing.ClassVar[type]
13+
PyInt: typing.ClassVar[type]
14+
15+
class Annotation:
16+
def __new__(
17+
cls,
18+
) -> Annotation: ...
19+
20+
class AnnotatedType:
21+
inner: Type
22+
annotation: Annotation
23+
24+
def __new__(cls, inner: Type, annotation: Annotation) -> AnnotatedType: ...
25+
26+
class AnnotatedTypeObject:
27+
annotated_type: AnnotatedType
28+
value: typing.Any
29+
30+
def __new__(
31+
cls, annotated_type: AnnotatedType, value: typing.Any
32+
) -> AnnotatedTypeObject: ...
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// This file is dual licensed under the terms of the Apache License, Version
2+
// 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
// for complete details.
4+
5+
use asn1::Asn1Writable;
6+
use pyo3::types::PyAnyMethods;
7+
8+
use crate::declarative_asn1::types as asn1_types;
9+
10+
#[pyo3::pyfunction]
11+
pub(crate) fn encode_der<'p>(
12+
py: pyo3::Python<'p>,
13+
value: &pyo3::Bound<'p, pyo3::types::PyAny>,
14+
) -> pyo3::PyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
15+
let class = value.get_type();
16+
17+
// TODO error messages are lost since asn1::WriteError does not allow
18+
// specifying error messages
19+
let encoded_bytes = if let Ok(root) = class.getattr("__asn1_root__") {
20+
let root = root.downcast::<asn1_types::AnnotatedType>().map_err(|_| {
21+
pyo3::exceptions::PyValueError::new_err(
22+
"target type has invalid annotations".to_string(),
23+
)
24+
})?;
25+
let object = asn1_types::AnnotatedTypeObject {
26+
annotated_type: root.get(),
27+
value: value.clone(),
28+
};
29+
asn1::write(|writer| object.write(writer))
30+
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?
31+
} else {
32+
// Handle simple types directly
33+
let annotated_type = asn1_types::non_root_type_to_annotated(py, &class, None)?;
34+
let object = asn1_types::AnnotatedTypeObject {
35+
annotated_type: &annotated_type,
36+
value: value.clone(),
37+
};
38+
asn1::write(|writer| object.write(writer))
39+
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?
40+
};
41+
42+
Ok(pyo3::types::PyBytes::new(py, &encoded_bytes))
43+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// This file is dual licensed under the terms of the Apache License, Version
2+
// 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
// for complete details.
4+
5+
use asn1::{SimpleAsn1Writable, Writer};
6+
use pyo3::types::PyAnyMethods;
7+
8+
use crate::declarative_asn1::types::{AnnotatedType, AnnotatedTypeObject, Type};
9+
10+
fn write_value<T: SimpleAsn1Writable>(
11+
writer: &mut Writer<'_>,
12+
value: &T,
13+
) -> Result<(), asn1::WriteError> {
14+
writer.write_element(value)
15+
}
16+
17+
impl asn1::Asn1Writable for AnnotatedTypeObject<'_> {
18+
fn encoded_length(&self) -> Option<usize> {
19+
None
20+
}
21+
22+
fn write(&self, writer: &mut Writer<'_>) -> Result<(), asn1::WriteError> {
23+
let value: pyo3::Bound<'_, pyo3::PyAny> = self.value.clone();
24+
let py = value.py();
25+
let annotated_type = self.annotated_type;
26+
27+
let inner = annotated_type.inner.get();
28+
match &inner {
29+
Type::Sequence(_cls, fields) => write_value(
30+
writer,
31+
&asn1::SequenceWriter::new(&|w| {
32+
for (name, ann_type) in fields.bind(py).into_iter() {
33+
let name = name
34+
.downcast::<pyo3::types::PyString>()
35+
.map_err(|_| asn1::WriteError::AllocationError)?;
36+
let ann_type = ann_type
37+
.downcast::<AnnotatedType>()
38+
.map_err(|_| asn1::WriteError::AllocationError)?;
39+
let object = AnnotatedTypeObject {
40+
annotated_type: ann_type.get(),
41+
value: self
42+
.value
43+
.getattr(name)
44+
.map_err(|_| asn1::WriteError::AllocationError)?,
45+
};
46+
w.write_element(&object)?;
47+
}
48+
Ok(())
49+
}),
50+
),
51+
Type::PyInt() => {
52+
let val: i64 = value
53+
.extract()
54+
.map_err(|_| asn1::WriteError::AllocationError)?;
55+
write_value(writer, &val)
56+
}
57+
}
58+
}
59+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// This file is dual licensed under the terms of the Apache License, Version
2+
// 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
// for complete details.
4+
5+
pub(crate) mod asn1;
6+
pub(crate) mod encode;
7+
pub(crate) mod types;

0 commit comments

Comments
 (0)