Repository: pyca/cryptography

Issue:
CertificateSigningRequestBuilder.add_attribute doesn't derive attribute ASN.1 type from value bytes
cryptography 37.0.0.dev1
pyOpenSSL 22.0.0

I am trying to make a CSR with a EnrollmentNameValuePair value.
(See https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wcce/92f07a54-2889-45e3-afd0-94b60daa80ec)

Reproducer:
```
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID

csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([
    x509.NameAttribute(NameOID.COMMON_NAME, "test")
]))

szOID_ENROLLMENT_NAME_VALUE_PAIR = x509.ObjectIdentifier("1.3.6.1.4.1.311.13.2.1")

csr = csr.add_attribute(
    szOID_ENROLLMENT_NAME_VALUE_PAIR,
    bytes.fromhex("302C1E0600530041004E1E2200750070006E003D00410064006D0069006E006900730074007200610074006F0072")
)

key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
cert = csr.sign(key, hashes.SHA256())
bytes = cert.public_bytes(Encoding.PEM)
c = x509.load_pem_x509_csr(bytes)

attribute = c.attributes.get_attribute_for_oid(szOID_ENROLLMENT_NAME_VALUE_PAIR)
print(attribute._type)
```

This prints '12' (decimal), for type UTF8String. However, the first byte 0x30 is for SEQUENCE.

The attribute value bytes are an object with the following structure:
```
EnrollmentNameValuePair ::= SEQUENCE {
         name                BMPSTRING,
         value               BMPSTRING
 }  --#public
```

Where 'name' is 'SAN' and 'value' is 'upn=Administrator'

It looks like the type is instantiated here and not changed afterwards:
https://github.com/pyca/cryptography/blob/b0df70cd2deb8ae8d893a4701f3cb27bda04d5de/src/cryptography/x509/base.py#L81-L87

In the CSR, the bytes I provided in the attribute get wrapped in "0C XX", so the bytes are 0C 2E 30 2C 1E..... instead of just 30 2C 1E.....
where "30" is the start of the SEQUENCE.

I would think that the type should be derived from the first byte of the value provided. Please let me know if I'm wrong.




Discussion:
Right now attributes are _always_ strings, meaning we wrap the value provided in a TLV of the string type. You can see this implemented here: https://github.com/pyca/cryptography/blob/main/src/rust/src/x509/csr.rs#L426-L442

If there's a desire to allow users to provide full TLVs that'd have to be considered as a new API.
For anyone else that want more control over adding attributes, I made some changes and got it working. I got rid of the error check for the value being UTF8.

I am not sure if my changes are any good for use cases other than my own. Curious to hear others' thoughts. My changes are below:

https://github.com/pyca/cryptography/blob/main/src/rust/src/x509/csr.rs#L426-L442
```
for py_attr in builder.getattr("_attributes")?.iter()? {
        let (py_oid, value, tag): (&pyo3::PyAny, &[u8], u8) = py_attr?.extract()?;
        let oid = py_oid_to_oid(py_oid)?;
        attrs.push(Attribute {
            type_id: oid,
            values: x509::Asn1ReadableOrWritable::new_write(asn1::SetOfWriter::new([
                x509::common::RawTlv::new(tag, value),
            ])),
        })
    }
```

https://github.com/pyca/cryptography/blob/main/src/cryptography/x509/base.py#L555
```
attributes: typing.List[typing.Tuple[ObjectIdentifier, bytes, int]] = [],
```

https://github.com/pyca/cryptography/blob/main/src/cryptography/x509/base.py#L595
```
self, oid: ObjectIdentifier, value: bytes, tag: int = 12
```

https://github.com/pyca/cryptography/blob/main/src/cryptography/x509/base.py#L611
(Should probably add an int type check for tag)
```
self._attributes + [(oid, value, tag)],
```

Then I use it like:
```
szOID_ENROLLMENT_NAME_VALUE_PAIR = x509.ObjectIdentifier("1.3.6.1.4.1.311.13.2.1")
csr = csr.add_attribute(
                szOID_ENROLLMENT_NAME_VALUE_PAIR,
bytes.fromhex("1E0600530041004E1E2200750070006E003D00410064006D0069006E006900730074007200610074006F0072"),
                48 # 0x30 hex, ASN.1 tag for SEQUENCE
            )
```
If you're interested, please send a pull request and we can review and see
if we can land.

On Fri, Mar 18, 2022 at 2:34 PM phughesion ***@***.***> wrote:

> For anyone else that want more control over adding attributes, I made some
> changes and got it working. I got rid of the error check for the value
> being UTF8.
>
>
> https://github.com/pyca/cryptography/blob/main/src/rust/src/x509/csr.rs#L426-L442
>
> for py_attr in builder.getattr("_attributes")?.iter()? {
>         let (py_oid, value, tag): (&pyo3::PyAny, &[u8], u8) = py_attr?.extract()?;
>         let oid = py_oid_to_oid(py_oid)?;
>         attrs.push(Attribute {
>             type_id: oid,
>             values: x509::Asn1ReadableOrWritable::new_write(asn1::SetOfWriter::new([
>                 x509::common::RawTlv::new(tag, value),
>             ])),
>         })
>     }
>
>
> https://github.com/pyca/cryptography/blob/main/src/cryptography/x509/base.py#L555
>
> attributes: typing.List[typing.Tuple[ObjectIdentifier, bytes, int]] = [],
>
>
> https://github.com/pyca/cryptography/blob/main/src/cryptography/x509/base.py#L595
>
> self, oid: ObjectIdentifier, value: bytes, tag: int = 12
>
>
> https://github.com/pyca/cryptography/blob/main/src/cryptography/x509/base.py#L611
> (Should probably add an int type check for tag)
>
> self._attributes + [(oid, value, tag)],
>
> Then I use it like:
>
> szOID_ENROLLMENT_NAME_VALUE_PAIR = x509.ObjectIdentifier("1.3.6.1.4.1.311.13.2.1")
> csr = csr.add_attribute(
>                 szOID_ENROLLMENT_NAME_VALUE_PAIR,
> bytes.fromhex("1E0600530041004E1E2200750070006E003D00410064006D0069006E006900730074007200610074006F0072"),
>                 48 # 0x30 hex, ASN.1 tag for SEQUENCE
>             )
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/pyca/cryptography/issues/6973#issuecomment-1072693884>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAAAGBGI65IKBMRVVXVNLGTVATEDFANCNFSM5Q72L2RQ>
> .
> 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>.
>
> You are receiving this because you commented.Message ID:
> ***@***.***>
>


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

#6976 


Resolving patch:
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
@@ -56,10 +56,12 @@ def _reject_duplicate_extension(
 
 def _reject_duplicate_attribute(
     oid: ObjectIdentifier,
-    attributes: typing.List[typing.Tuple[ObjectIdentifier, bytes]],
+    attributes: typing.List[
+        typing.Tuple[ObjectIdentifier, bytes, typing.Optional[int]]
+    ],
 ) -> None:
     # This is quadratic in the number of attributes
-    for attr_oid, _ in attributes:
+    for attr_oid, _, _ in attributes:
         if attr_oid == oid:
             raise ValueError("This attribute has already been set.")
 
@@ -552,7 +554,9 @@ def __init__(
         self,
         subject_name: typing.Optional[Name] = None,
         extensions: typing.List[Extension[ExtensionType]] = [],
-        attributes: typing.List[typing.Tuple[ObjectIdentifier, bytes]] = [],
+        attributes: typing.List[
+            typing.Tuple[ObjectIdentifier, bytes, typing.Optional[int]]
+        ] = [],
     ):
         """
         Creates an empty X.509 certificate request (v1).
@@ -592,7 +596,11 @@ def add_extension(
         )
 
     def add_attribute(
-        self, oid: ObjectIdentifier, value: bytes
+        self,
+        oid: ObjectIdentifier,
+        value: bytes,
+        *,
+        _tag: typing.Optional[_ASN1Type] = None,
     ) -> "CertificateSigningRequestBuilder":
         """
         Adds an X.509 attribute with an OID and associated value.
@@ -603,12 +611,20 @@ def add_attribute(
         if not isinstance(value, bytes):
             raise TypeError("value must be bytes")
 
+        if _tag is not None and not isinstance(_tag, _ASN1Type):
+            raise TypeError("tag must be _ASN1Type")
+
         _reject_duplicate_attribute(oid, self._attributes)
 
+        if _tag is not None:
+            tag = _tag.value
+        else:
+            tag = None
+
         return CertificateSigningRequestBuilder(
             self._subject_name,
             self._extensions,
-            self._attributes + [(oid, value)],
+            self._attributes + [(oid, value, tag)],
         )
 
     def sign(


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
@@ -4243,6 +4243,36 @@ def test_duplicate_attribute(self, backend):
                 x509.oid.AttributeOID.CHALLENGE_PASSWORD, b"val2"
             )
 
+    def test_add_attribute_tag(self, backend):
+        private_key = ec.generate_private_key(ec.SECP256R1(), backend)
+        builder = (
+            x509.CertificateSigningRequestBuilder()
+            .subject_name(x509.Name([]))
+            .add_attribute(
+                x509.ObjectIdentifier("1.2.3.4"),
+                b"\x00\x00",
+                _tag=_ASN1Type.GeneralizedTime,
+            )
+        )
+        request = builder.sign(private_key, hashes.SHA256(), backend)
+        attr = request.attributes.get_attribute_for_oid(
+            x509.ObjectIdentifier("1.2.3.4")
+        )
+
+        assert attr.value == b"\x00\x00"
+        assert attr._type == _ASN1Type.GeneralizedTime.value
+
+    def test_add_attribute_tag_non_int(self, backend):
+        builder = x509.CertificateSigningRequestBuilder().subject_name(
+            x509.Name([])
+        )
+        with pytest.raises(TypeError):
+            builder.add_attribute(
+                x509.ObjectIdentifier("1.2.3.4"),
+                b"",
+                _tag=object(),  # type:ignore[arg-type]
+            )
+
     def test_set_subject_twice(self):
         builder = x509.CertificateSigningRequestBuilder()
         builder = builder.subject_name(