Repository: pyca/cryptography

Issue:
[Feature request] CertificateSigningRequest object needs attributes member
CSR's can have both attributes and extensions.

There's a variable `.extensions` (type `Extensions`) that gives the list of `Extension`.  But there is no variable `.attributes` (type `Attributes`) that gives the corresponding list of `Attribute`.

Please extend the abstraction.


Discussion:
A few complexities to work through here:

* "Extensions" in CSRs are, in reality, an attribute that encodes an extensions structure. Do we want to expose that in this list or keep it separated in the `extensions` attribute we have?
* If we want to list it should we return an `Extensions` object in that case and raw bytes in the others?
* What should we return? When adding an attribute in the CSRBuilder we take `(oid, utf8_bytes)`. Do we want `csr.attributes` to return `List[Tuple[ObjectIdentifier, bytes]]` or should we create an `Attribute` object to hold those two values?
* Our code currently requires that any non-extension attribute be utf8string ASN.1. That's not necessarily a problem, but it's something to be aware of depending on how outside the lines someone wants to go with attributes.
My suggestions:

- .attributes should return an Attributes which is an iterator over (oid,
bytes) -- not sure if that should really be a tuple or an Attribtue object
- The extensions attribute should just be there like a normal pile of
bytes, if you want structured data use .extensions
- UTF8string was a mistake

On Tue, Nov 2, 2021 at 7:24 AM Paul Kehrer ***@***.***> wrote:

> A few complexities to work through here:
>
>    - "Extensions" in CSRs are, in reality, an attribute that encodes an
>    extensions structure. Do we want to expose that in this list or keep it
>    separated in the extensions attribute we have?
>    - If we want to list it should we return an Extensions object in that
>    case and raw bytes in the others?
>    - What should we return? When adding an attribute in the CSRBuilder we
>    take (oid, utf8_bytes). Do we want csr.attributes to return List[Tuple[ObjectIdentifier,
>    bytes]] or should we create an Attribute object to hold those two
>    values?
>    - Our code currently requires that any non-extension attribute be
>    utf8string ASN.1. That's not necessarily a problem, but it's something to
>    be aware of depending on how outside the lines someone wants to go with
>    attributes.
>
> —
> You are receiving this because you are subscribed to this thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/pyca/cryptography/issues/6056#issuecomment-957350521>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAAAGBGJHJSJKKDW6C2NPSTUJ7DALANCNFSM45FRTAJA>
> .
> Triage notifications on the go with GitHub Mobile for iOS
> <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
> or Android
> <https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>.
>
>


-- 
All that is necessary for evil to succeed is for good people to do nothing.


Resolving patch:
diff --git a/src/cryptography/utils.py b/src/cryptography/utils.py
--- a/src/cryptography/utils.py
+++ b/src/cryptography/utils.py
@@ -25,6 +25,7 @@ class CryptographyDeprecationWarning(UserWarning):
 PersistentlyDeprecated2019 = CryptographyDeprecationWarning
 DeprecatedIn34 = CryptographyDeprecationWarning
 DeprecatedIn35 = CryptographyDeprecationWarning
+DeprecatedIn36 = CryptographyDeprecationWarning
 
 
 def _check_bytes(name: str, value: bytes) -> None:
diff --git a/src/cryptography/x509/__init__.py b/src/cryptography/x509/__init__.py
--- a/src/cryptography/x509/__init__.py
+++ b/src/cryptography/x509/__init__.py
@@ -5,7 +5,9 @@
 
 from cryptography.x509 import certificate_transparency
 from cryptography.x509.base import (
+    Attribute,
     AttributeNotFound,
+    Attributes,
     Certificate,
     CertificateBuilder,
     CertificateRevocationList,
@@ -173,7 +175,9 @@
     "load_pem_x509_crl",
     "load_der_x509_crl",
     "random_serial_number",
+    "Attribute",
     "AttributeNotFound",
+    "Attributes",
     "InvalidVersion",
     "DeltaCRLIndicator",
     "DuplicateExtension",
diff --git a/src/cryptography/x509/base.py b/src/cryptography/x509/base.py
--- a/src/cryptography/x509/base.py
+++ b/src/cryptography/x509/base.py
@@ -25,8 +25,13 @@
     PRIVATE_KEY_TYPES as PRIVATE_KEY_TYPES,
     PUBLIC_KEY_TYPES as PUBLIC_KEY_TYPES,
 )
-from cryptography.x509.extensions import Extension, ExtensionType, Extensions
-from cryptography.x509.name import Name
+from cryptography.x509.extensions import (
+    Extension,
+    ExtensionType,
+    Extensions,
+    _make_sequence_methods,
+)
+from cryptography.x509.name import Name, _ASN1Type
 from cryptography.x509.oid import ObjectIdentifier
 
 
@@ -73,6 +78,65 @@ def _convert_to_naive_utc_time(time: datetime.datetime) -> datetime.datetime:
         return time
 
 
+class Attribute:
+    def __init__(
+        self,
+        oid: ObjectIdentifier,
+        value: bytes,
+        _type: int = _ASN1Type.UTF8String.value,
+    ) -> None:
+        self._oid = oid
+        self._value = value
+        self._type = _type
+
+    @property
+    def oid(self) -> ObjectIdentifier:
+        return self._oid
+
+    @property
+    def value(self) -> bytes:
+        return self._value
+
+    def __repr__(self):
+        return "<Attribute(oid={}, value={!r})>".format(self.oid, self.value)
+
+    def __eq__(self, other: typing.Any) -> bool:
+        if not isinstance(other, Attribute):
+            return NotImplemented
+
+        return (
+            self.oid == other.oid
+            and self.value == other.value
+            and self._type == other._type
+        )
+
+    def __ne__(self, other: typing.Any) -> bool:
+        return not self == other
+
+    def __hash__(self) -> int:
+        return hash((self.oid, self.value, self._type))
+
+
+class Attributes:
+    def __init__(
+        self,
+        attributes: typing.Iterable[Attribute],
+    ) -> None:
+        self._attributes = list(attributes)
+
+    __len__, __iter__, __getitem__ = _make_sequence_methods("_attributes")
+
+    def __repr__(self):
+        return "<Attributes({})>".format(self._attributes)
+
+    def get_attribute_for_oid(self, oid: ObjectIdentifier) -> Attribute:
+        for attr in self:
+            if attr.oid == oid:
+                return attr
+
+        raise AttributeNotFound("No {} attribute was found".format(oid), oid)
+
+
 class Version(utils.Enum):
     v1 = 0
     v3 = 2
@@ -418,6 +482,12 @@ def extensions(self) -> Extensions:
         Returns the extensions in the signing request.
         """
 
+    @abc.abstractproperty
+    def attributes(self) -> Attributes:
+        """
+        Returns an Attributes object.
+        """
+
     @abc.abstractmethod
     def public_bytes(self, encoding: serialization.Encoding) -> bytes:
         """


Test patch:
diff --git a/tests/x509/test_x509.py b/tests/x509/test_x509.py
--- a/tests/x509/test_x509.py
+++ b/tests/x509/test_x509.py
@@ -1369,79 +1369,6 @@ def test_invalid_pem(self, backend):
         with pytest.raises(ValueError, match="Valid PEM but no"):
             x509.load_pem_x509_csr(crl, backend)
 
-    def test_get_attribute_for_oid_challenge(self, backend):
-        request = _load_cert(
-            os.path.join("x509", "requests", "challenge.pem"),
-            x509.load_pem_x509_csr,
-            backend,
-        )
-        assert (
-            request.get_attribute_for_oid(
-                x509.oid.AttributeOID.CHALLENGE_PASSWORD
-            )
-            == b"challenge me!"
-        )
-
-    def test_get_attribute_for_oid_multiple(self, backend):
-        request = _load_cert(
-            os.path.join("x509", "requests", "challenge-unstructured.pem"),
-            x509.load_pem_x509_csr,
-            backend,
-        )
-        assert (
-            request.get_attribute_for_oid(
-                x509.oid.AttributeOID.CHALLENGE_PASSWORD
-            )
-            == b"beauty"
-        )
-        assert (
-            request.get_attribute_for_oid(
-                x509.oid.AttributeOID.UNSTRUCTURED_NAME
-            )
-            == b"an unstructured field"
-        )
-
-    def test_invalid_attribute_for_oid(self, backend):
-        """
-        We only support a few string types at the moment. This can
-        be expanded if we find use cases.
-        """
-        request = _load_cert(
-            os.path.join("x509", "requests", "challenge-invalid.der"),
-            x509.load_der_x509_csr,
-            backend,
-        )
-        with pytest.raises(ValueError):
-            request.get_attribute_for_oid(
-                x509.oid.AttributeOID.CHALLENGE_PASSWORD
-            )
-
-    def test_challenge_multivalued(self, backend):
-        """
-        We only support single-valued SETs in our X509 request attributes
-        """
-        request = _load_cert(
-            os.path.join("x509", "requests", "challenge-multi-valued.der"),
-            x509.load_der_x509_csr,
-            backend,
-        )
-        with pytest.raises(ValueError, match="Only single-valued"):
-            request.get_attribute_for_oid(
-                x509.oid.AttributeOID.CHALLENGE_PASSWORD
-            )
-
-    def test_no_challenge_password(self, backend):
-        request = _load_cert(
-            os.path.join("x509", "requests", "rsa_sha256.pem"),
-            x509.load_pem_x509_csr,
-            backend,
-        )
-        with pytest.raises(x509.AttributeNotFound) as exc:
-            request.get_attribute_for_oid(
-                x509.oid.AttributeOID.CHALLENGE_PASSWORD
-            )
-        assert exc.value.oid == x509.oid.AttributeOID.CHALLENGE_PASSWORD
-
     @pytest.mark.parametrize(
         "loader_func", [x509.load_pem_x509_csr, x509.load_der_x509_csr]
     )
@@ -4118,6 +4045,16 @@ def test_add_attributes(self, backend):
                 x509.oid.AttributeOID.UNSTRUCTURED_NAME, unstructured_name
             )
             .add_attribute(x509.oid.NameOID.LOCALITY_NAME, locality)
+            .add_extension(
+                x509.ExtendedKeyUsage(
+                    [
+                        ExtendedKeyUsageOID.CLIENT_AUTH,
+                        ExtendedKeyUsageOID.SERVER_AUTH,
+                        ExtendedKeyUsageOID.CODE_SIGNING,
+                    ]
+                ),
+                False,
+            )
             .sign(private_key, hashes.SHA256(), backend)
         )
 
@@ -4137,6 +4074,7 @@ def test_add_attributes(self, backend):
             request.get_attribute_for_oid(x509.oid.NameOID.LOCALITY_NAME)
             == locality
         )
+        assert len(request.attributes) == 4
 
     def test_add_attributes_non_utf8(self, backend):
         _skip_curve_unsupported(backend, ec.SECP256R1())
@@ -5293,3 +5231,266 @@ def notrandom(size):
 
     assert serial_number == int.from_bytes(sample_data, "big") >> 1
     assert serial_number.bit_length() < 160
+
+
+class TestAttribute:
+    def test_eq(self):
+        attr1 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"value",
+        )
+        attr2 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"value",
+        )
+        assert attr1 == attr2
+
+    def test_ne(self):
+        attr1 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"value",
+        )
+        attr2 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"value",
+            _ASN1Type.IA5String.value,
+        )
+        attr3 = x509.Attribute(
+            x509.oid.AttributeOID.UNSTRUCTURED_NAME,
+            b"value",
+        )
+        attr4 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"other value",
+        )
+        assert attr1 != attr2
+        assert attr1 != attr3
+        assert attr1 != attr4
+        assert attr1 != object()
+
+    def test_repr(self):
+        attr1 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"value",
+        )
+        assert repr(attr1) == (
+            "<Attribute(oid=<ObjectIdentifier(oid=1.2.840.113549.1.9.7, name="
+            "challengePassword)>, value=b'value')>"
+        )
+
+    def test_hash(self):
+        attr1 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"value",
+            _ASN1Type.UTF8String.value,
+        )
+        attr2 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"value",
+            _ASN1Type.UTF8String.value,
+        )
+        attr3 = x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"value",
+            _ASN1Type.IA5String.value,
+        )
+        assert hash(attr1) == hash(attr2)
+        assert hash(attr1) != hash(attr3)
+
+
+class TestAttributes:
+    def test_no_attributes(self):
+        attrs = x509.Attributes([])
+        assert len(attrs) == 0
+
+    def test_get_attribute_for_oid(self):
+        attr_list = [
+            x509.Attribute(
+                x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+                b"nonsense",
+            ),
+            x509.Attribute(
+                x509.oid.AttributeOID.UNSTRUCTURED_NAME,
+                b"montessori",
+                _ASN1Type.PrintableString.value,
+            ),
+        ]
+        attrs = x509.Attributes(attr_list)
+        attr = attrs.get_attribute_for_oid(
+            x509.oid.AttributeOID.UNSTRUCTURED_NAME
+        )
+        assert attr.oid == x509.oid.AttributeOID.UNSTRUCTURED_NAME
+        assert attr.value == b"montessori"
+        assert attr._type == _ASN1Type.PrintableString.value
+
+    def test_indexing(self):
+        attr_list = [
+            x509.Attribute(
+                x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+                b"nonsense",
+            ),
+            x509.Attribute(
+                x509.oid.AttributeOID.UNSTRUCTURED_NAME,
+                b"montessori",
+            ),
+            x509.Attribute(
+                x509.ObjectIdentifier("2.999.2"),
+                b"meaningless",
+            ),
+            x509.Attribute(
+                x509.ObjectIdentifier("2.999.1"),
+                b"meaningless",
+            ),
+        ]
+        attrs = x509.Attributes(attr_list)
+        assert len(attrs) == 4
+        assert list(attrs) == attr_list
+        assert attrs[-1] == attrs[3]
+        assert attrs[0:3:2] == [attrs[0], attrs[2]]
+
+    def test_get_attribute_not_found(self):
+        attrs = x509.Attributes([])
+        with pytest.raises(x509.AttributeNotFound) as exc:
+            attrs.get_attribute_for_oid(
+                x509.oid.AttributeOID.CHALLENGE_PASSWORD
+            )
+        assert exc.value.oid == x509.oid.AttributeOID.CHALLENGE_PASSWORD
+
+    def test_repr(self):
+        attrs = x509.Attributes(
+            [
+                x509.Attribute(
+                    x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+                    b"nonsense",
+                ),
+            ]
+        )
+        assert repr(attrs) == (
+            "<Attributes([<Attribute(oid=<ObjectIdentifier(oid=1.2.840.11354"
+            "9.1.9.7, name=challengePassword)>, value=b'nonsense')>])>"
+        )
+
+
+class TestRequestAttributes:
+    def test_get_attribute_for_oid_challenge(self, backend):
+        request = _load_cert(
+            os.path.join("x509", "requests", "challenge.pem"),
+            x509.load_pem_x509_csr,
+            backend,
+        )
+        with pytest.warns(utils.DeprecatedIn36):
+            assert (
+                request.get_attribute_for_oid(
+                    x509.oid.AttributeOID.CHALLENGE_PASSWORD
+                )
+                == b"challenge me!"
+            )
+
+        assert request.attributes.get_attribute_for_oid(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD
+        ) == x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"challenge me!",
+        )
+
+    def test_get_attribute_for_oid_multiple(self, backend):
+        request = _load_cert(
+            os.path.join("x509", "requests", "challenge-unstructured.pem"),
+            x509.load_pem_x509_csr,
+            backend,
+        )
+        with pytest.warns(utils.DeprecatedIn36):
+            assert (
+                request.get_attribute_for_oid(
+                    x509.oid.AttributeOID.CHALLENGE_PASSWORD
+                )
+                == b"beauty"
+            )
+
+        with pytest.warns(utils.DeprecatedIn36):
+            assert (
+                request.get_attribute_for_oid(
+                    x509.oid.AttributeOID.UNSTRUCTURED_NAME
+                )
+                == b"an unstructured field"
+            )
+
+        assert request.attributes.get_attribute_for_oid(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD
+        ) == x509.Attribute(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD,
+            b"beauty",
+        )
+
+        assert request.attributes.get_attribute_for_oid(
+            x509.oid.AttributeOID.UNSTRUCTURED_NAME
+        ) == x509.Attribute(
+            x509.oid.AttributeOID.UNSTRUCTURED_NAME,
+            b"an unstructured field",
+        )
+
+    def test_unsupported_asn1_type_in_attribute(self, backend):
+        request = _load_cert(
+            os.path.join("x509", "requests", "challenge-invalid.der"),
+            x509.load_der_x509_csr,
+            backend,
+        )
+
+        # Unsupported in the legacy path
+        with pytest.raises(ValueError):
+            with pytest.warns(utils.DeprecatedIn36):
+                request.get_attribute_for_oid(
+                    x509.oid.AttributeOID.CHALLENGE_PASSWORD
+                )
+
+        # supported in the new path where we just store the type and
+        # return raw bytes
+        attr = request.attributes.get_attribute_for_oid(
+            x509.oid.AttributeOID.CHALLENGE_PASSWORD
+        )
+        assert attr._type == 2
+
+    def test_challenge_multivalued(self, backend):
+        """
+        We only support single-valued SETs in our X509 request attributes
+        """
+        request = _load_cert(
+            os.path.join("x509", "requests", "challenge-multi-valued.der"),
+            x509.load_der_x509_csr,
+            backend,
+        )
+        with pytest.raises(ValueError, match="Only single-valued"):
+            with pytest.warns(utils.DeprecatedIn36):
+                request.get_attribute_for_oid(
+                    x509.oid.AttributeOID.CHALLENGE_PASSWORD
+                )
+
+        with pytest.raises(ValueError, match="Only single-valued"):
+            request.attributes
+
+    def test_no_challenge_password(self, backend):
+        request = _load_cert(
+            os.path.join("x509", "requests", "rsa_sha256.pem"),
+            x509.load_pem_x509_csr,
+            backend,
+        )
+        with pytest.raises(x509.AttributeNotFound) as exc:
+            with pytest.warns(utils.DeprecatedIn36):
+                request.get_attribute_for_oid(
+                    x509.oid.AttributeOID.CHALLENGE_PASSWORD
+                )
+        assert exc.value.oid == x509.oid.AttributeOID.CHALLENGE_PASSWORD
+
+        with pytest.raises(x509.AttributeNotFound) as exc:
+            request.attributes.get_attribute_for_oid(
+                x509.oid.AttributeOID.CHALLENGE_PASSWORD
+            )
+        assert exc.value.oid == x509.oid.AttributeOID.CHALLENGE_PASSWORD
+
+    def test_no_attributes(self, backend):
+        request = _load_cert(
+            os.path.join("x509", "requests", "rsa_sha256.pem"),
+            x509.load_pem_x509_csr,
+            backend,
+        )
+        assert len(request.attributes) == 0