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
5from __future__ import annotations
6
7import binascii
8import re
9import sys
10import typing
11import warnings
12
13from cryptography import utils
14from cryptography.hazmat.bindings._rust import x509 as rust_x509
15from cryptography.x509.oid import NameOID, ObjectIdentifier
16
17
18class _ASN1Type(utils.Enum):
19 BitString = 3
20 OctetString = 4
21 UTF8String = 12
22 NumericString = 18
23 PrintableString = 19
24 T61String = 20
25 IA5String = 22
26 UTCTime = 23
27 GeneralizedTime = 24
28 VisibleString = 26
29 UniversalString = 28
30 BMPString = 30
31
32
33_ASN1_TYPE_TO_ENUM = {i.value: i for i in _ASN1Type}
34_NAMEOID_DEFAULT_TYPE: dict[ObjectIdentifier, _ASN1Type] = {
35 NameOID.COUNTRY_NAME: _ASN1Type.PrintableString,
36 NameOID.JURISDICTION_COUNTRY_NAME: _ASN1Type.PrintableString,
37 NameOID.SERIAL_NUMBER: _ASN1Type.PrintableString,
38 NameOID.DN_QUALIFIER: _ASN1Type.PrintableString,
39 NameOID.EMAIL_ADDRESS: _ASN1Type.IA5String,
40 NameOID.DOMAIN_COMPONENT: _ASN1Type.IA5String,
41}
42
43# Type alias
44_OidNameMap = typing.Mapping[ObjectIdentifier, str]
45_NameOidMap = typing.Mapping[str, ObjectIdentifier]
46
47#: Short attribute names from RFC 4514:
48#: https://tools.ietf.org/html/rfc4514#page-7
49_NAMEOID_TO_NAME: _OidNameMap = {
50 NameOID.COMMON_NAME: "CN",
51 NameOID.LOCALITY_NAME: "L",
52 NameOID.STATE_OR_PROVINCE_NAME: "ST",
53 NameOID.ORGANIZATION_NAME: "O",
54 NameOID.ORGANIZATIONAL_UNIT_NAME: "OU",
55 NameOID.COUNTRY_NAME: "C",
56 NameOID.STREET_ADDRESS: "STREET",
57 NameOID.DOMAIN_COMPONENT: "DC",
58 NameOID.USER_ID: "UID",
59}
60_NAME_TO_NAMEOID = {v: k for k, v in _NAMEOID_TO_NAME.items()}
61
62
63def _escape_dn_value(val: str | bytes) -> str:
64 """Escape special characters in RFC4514 Distinguished Name value."""
65
66 if not val:
67 return ""
68
69 # RFC 4514 Section 2.4 defines the value as being the # (U+0023) character
70 # followed by the hexadecimal encoding of the octets.
71 if isinstance(val, bytes):
72 return "#" + binascii.hexlify(val).decode("utf8")
73
74 # See https://tools.ietf.org/html/rfc4514#section-2.4
75 val = val.replace("\\", "\\\\")
76 val = val.replace('"', '\\"')
77 val = val.replace("+", "\\+")
78 val = val.replace(",", "\\,")
79 val = val.replace(";", "\\;")
80 val = val.replace("<", "\\<")
81 val = val.replace(">", "\\>")
82 val = val.replace("\0", "\\00")
83
84 if val[0] in ("#", " "):
85 val = "\\" + val
86 if val[-1] == " ":
87 val = val[:-1] + "\\ "
88
89 return val
90
91
92def _unescape_dn_value(val: str) -> str:
93 if not val:
94 return ""
95
96 # See https://tools.ietf.org/html/rfc4514#section-3
97
98 # special = escaped / SPACE / SHARP / EQUALS
99 # escaped = DQUOTE / PLUS / COMMA / SEMI / LANGLE / RANGLE
100 def sub(m):
101 val = m.group(1)
102 # Regular escape
103 if len(val) == 1:
104 return val
105 # Hex-value scape
106 return chr(int(val, 16))
107
108 return _RFC4514NameParser._PAIR_RE.sub(sub, val)
109
110
111class NameAttribute:
112 def __init__(
113 self,
114 oid: ObjectIdentifier,
115 value: str | bytes,
116 _type: _ASN1Type | None = None,
117 *,
118 _validate: bool = True,
119 ) -> None:
120 if not isinstance(oid, ObjectIdentifier):
121 raise TypeError(
122 "oid argument must be an ObjectIdentifier instance."
123 )
124 if _type == _ASN1Type.BitString:
125 if oid != NameOID.X500_UNIQUE_IDENTIFIER:
126 raise TypeError(
127 "oid must be X500_UNIQUE_IDENTIFIER for BitString type."
128 )
129 if not isinstance(value, bytes):
130 raise TypeError("value must be bytes for BitString")
131 else:
132 if not isinstance(value, str):
133 raise TypeError("value argument must be a str")
134
135 if oid in (NameOID.COUNTRY_NAME, NameOID.JURISDICTION_COUNTRY_NAME):
136 assert isinstance(value, str)
137 c_len = len(value.encode("utf8"))
138 if c_len != 2 and _validate is True:
139 raise ValueError(
140 "Country name must be a 2 character country code"
141 )
142 elif c_len != 2:
143 warnings.warn(
144 "Country names should be two characters, but the "
145 f"attribute is {c_len} characters in length.",
146 stacklevel=2,
147 )
148
149 # The appropriate ASN1 string type varies by OID and is defined across
150 # multiple RFCs including 2459, 3280, and 5280. In general UTF8String
151 # is preferred (2459), but 3280 and 5280 specify several OIDs with
152 # alternate types. This means when we see the sentinel value we need
153 # to look up whether the OID has a non-UTF8 type. If it does, set it
154 # to that. Otherwise, UTF8!
155 if _type is None:
156 _type = _NAMEOID_DEFAULT_TYPE.get(oid, _ASN1Type.UTF8String)
157
158 if not isinstance(_type, _ASN1Type):
159 raise TypeError("_type must be from the _ASN1Type enum")
160
161 self._oid = oid
162 self._value = value
163 self._type = _type
164
165 @property
166 def oid(self) -> ObjectIdentifier:
167 return self._oid
168
169 @property
170 def value(self) -> str | bytes:
171 return self._value
172
173 @property
174 def rfc4514_attribute_name(self) -> str:
175 """
176 The short attribute name (for example "CN") if available,
177 otherwise the OID dotted string.
178 """
179 return _NAMEOID_TO_NAME.get(self.oid, self.oid.dotted_string)
180
181 def rfc4514_string(
182 self, attr_name_overrides: _OidNameMap | None = None
183 ) -> str:
184 """
185 Format as RFC4514 Distinguished Name string.
186
187 Use short attribute name if available, otherwise fall back to OID
188 dotted string.
189 """
190 attr_name = (
191 attr_name_overrides.get(self.oid) if attr_name_overrides else None
192 )
193 if attr_name is None:
194 attr_name = self.rfc4514_attribute_name
195
196 return f"{attr_name}={_escape_dn_value(self.value)}"
197
198 def __eq__(self, other: object) -> bool:
199 if not isinstance(other, NameAttribute):
200 return NotImplemented
201
202 return self.oid == other.oid and self.value == other.value
203
204 def __hash__(self) -> int:
205 return hash((self.oid, self.value))
206
207 def __repr__(self) -> str:
208 return f"<NameAttribute(oid={self.oid}, value={self.value!r})>"
209
210
211class RelativeDistinguishedName:
212 def __init__(self, attributes: typing.Iterable[NameAttribute]):
213 attributes = list(attributes)
214 if not attributes:
215 raise ValueError("a relative distinguished name cannot be empty")
216 if not all(isinstance(x, NameAttribute) for x in attributes):
217 raise TypeError("attributes must be an iterable of NameAttribute")
218
219 # Keep list and frozenset to preserve attribute order where it matters
220 self._attributes = attributes
221 self._attribute_set = frozenset(attributes)
222
223 if len(self._attribute_set) != len(attributes):
224 raise ValueError("duplicate attributes are not allowed")
225
226 def get_attributes_for_oid(
227 self, oid: ObjectIdentifier
228 ) -> list[NameAttribute]:
229 return [i for i in self if i.oid == oid]
230
231 def rfc4514_string(
232 self, attr_name_overrides: _OidNameMap | None = None
233 ) -> str:
234 """
235 Format as RFC4514 Distinguished Name string.
236
237 Within each RDN, attributes are joined by '+', although that is rarely
238 used in certificates.
239 """
240 return "+".join(
241 attr.rfc4514_string(attr_name_overrides)
242 for attr in self._attributes
243 )
244
245 def __eq__(self, other: object) -> bool:
246 if not isinstance(other, RelativeDistinguishedName):
247 return NotImplemented
248
249 return self._attribute_set == other._attribute_set
250
251 def __hash__(self) -> int:
252 return hash(self._attribute_set)
253
254 def __iter__(self) -> typing.Iterator[NameAttribute]:
255 return iter(self._attributes)
256
257 def __len__(self) -> int:
258 return len(self._attributes)
259
260 def __repr__(self) -> str:
261 return f"<RelativeDistinguishedName({self.rfc4514_string()})>"
262
263
264class Name:
265 @typing.overload
266 def __init__(self, attributes: typing.Iterable[NameAttribute]) -> None:
267 ...
268
269 @typing.overload
270 def __init__(
271 self, attributes: typing.Iterable[RelativeDistinguishedName]
272 ) -> None:
273 ...
274
275 def __init__(
276 self,
277 attributes: typing.Iterable[NameAttribute | RelativeDistinguishedName],
278 ) -> None:
279 attributes = list(attributes)
280 if all(isinstance(x, NameAttribute) for x in attributes):
281 self._attributes = [
282 RelativeDistinguishedName([typing.cast(NameAttribute, x)])
283 for x in attributes
284 ]
285 elif all(isinstance(x, RelativeDistinguishedName) for x in attributes):
286 self._attributes = typing.cast(
287 typing.List[RelativeDistinguishedName], attributes
288 )
289 else:
290 raise TypeError(
291 "attributes must be a list of NameAttribute"
292 " or a list RelativeDistinguishedName"
293 )
294
295 @classmethod
296 def from_rfc4514_string(
297 cls,
298 data: str,
299 attr_name_overrides: _NameOidMap | None = None,
300 ) -> Name:
301 return _RFC4514NameParser(data, attr_name_overrides or {}).parse()
302
303 def rfc4514_string(
304 self, attr_name_overrides: _OidNameMap | None = None
305 ) -> str:
306 """
307 Format as RFC4514 Distinguished Name string.
308 For example 'CN=foobar.com,O=Foo Corp,C=US'
309
310 An X.509 name is a two-level structure: a list of sets of attributes.
311 Each list element is separated by ',' and within each list element, set
312 elements are separated by '+'. The latter is almost never used in
313 real world certificates. According to RFC4514 section 2.1 the
314 RDNSequence must be reversed when converting to string representation.
315 """
316 return ",".join(
317 attr.rfc4514_string(attr_name_overrides)
318 for attr in reversed(self._attributes)
319 )
320
321 def get_attributes_for_oid(
322 self, oid: ObjectIdentifier
323 ) -> list[NameAttribute]:
324 return [i for i in self if i.oid == oid]
325
326 @property
327 def rdns(self) -> list[RelativeDistinguishedName]:
328 return self._attributes
329
330 def public_bytes(self, backend: typing.Any = None) -> bytes:
331 return rust_x509.encode_name_bytes(self)
332
333 def __eq__(self, other: object) -> bool:
334 if not isinstance(other, Name):
335 return NotImplemented
336
337 return self._attributes == other._attributes
338
339 def __hash__(self) -> int:
340 # TODO: this is relatively expensive, if this looks like a bottleneck
341 # for you, consider optimizing!
342 return hash(tuple(self._attributes))
343
344 def __iter__(self) -> typing.Iterator[NameAttribute]:
345 for rdn in self._attributes:
346 yield from rdn
347
348 def __len__(self) -> int:
349 return sum(len(rdn) for rdn in self._attributes)
350
351 def __repr__(self) -> str:
352 rdns = ",".join(attr.rfc4514_string() for attr in self._attributes)
353 return f"<Name({rdns})>"
354
355
356class _RFC4514NameParser:
357 _OID_RE = re.compile(r"(0|([1-9]\d*))(\.(0|([1-9]\d*)))+")
358 _DESCR_RE = re.compile(r"[a-zA-Z][a-zA-Z\d-]*")
359
360 _PAIR = r"\\([\\ #=\"\+,;<>]|[\da-zA-Z]{2})"
361 _PAIR_RE = re.compile(_PAIR)
362 _LUTF1 = r"[\x01-\x1f\x21\x24-\x2A\x2D-\x3A\x3D\x3F-\x5B\x5D-\x7F]"
363 _SUTF1 = r"[\x01-\x21\x23-\x2A\x2D-\x3A\x3D\x3F-\x5B\x5D-\x7F]"
364 _TUTF1 = r"[\x01-\x1F\x21\x23-\x2A\x2D-\x3A\x3D\x3F-\x5B\x5D-\x7F]"
365 _UTFMB = rf"[\x80-{chr(sys.maxunicode)}]"
366 _LEADCHAR = rf"{_LUTF1}|{_UTFMB}"
367 _STRINGCHAR = rf"{_SUTF1}|{_UTFMB}"
368 _TRAILCHAR = rf"{_TUTF1}|{_UTFMB}"
369 _STRING_RE = re.compile(
370 rf"""
371 (
372 ({_LEADCHAR}|{_PAIR})
373 (
374 ({_STRINGCHAR}|{_PAIR})*
375 ({_TRAILCHAR}|{_PAIR})
376 )?
377 )?
378 """,
379 re.VERBOSE,
380 )
381 _HEXSTRING_RE = re.compile(r"#([\da-zA-Z]{2})+")
382
383 def __init__(self, data: str, attr_name_overrides: _NameOidMap) -> None:
384 self._data = data
385 self._idx = 0
386
387 self._attr_name_overrides = attr_name_overrides
388
389 def _has_data(self) -> bool:
390 return self._idx < len(self._data)
391
392 def _peek(self) -> str | None:
393 if self._has_data():
394 return self._data[self._idx]
395 return None
396
397 def _read_char(self, ch: str) -> None:
398 if self._peek() != ch:
399 raise ValueError
400 self._idx += 1
401
402 def _read_re(self, pat) -> str:
403 match = pat.match(self._data, pos=self._idx)
404 if match is None:
405 raise ValueError
406 val = match.group()
407 self._idx += len(val)
408 return val
409
410 def parse(self) -> Name:
411 """
412 Parses the `data` string and converts it to a Name.
413
414 According to RFC4514 section 2.1 the RDNSequence must be
415 reversed when converting to string representation. So, when
416 we parse it, we need to reverse again to get the RDNs on the
417 correct order.
418 """
419 rdns = [self._parse_rdn()]
420
421 while self._has_data():
422 self._read_char(",")
423 rdns.append(self._parse_rdn())
424
425 return Name(reversed(rdns))
426
427 def _parse_rdn(self) -> RelativeDistinguishedName:
428 nas = [self._parse_na()]
429 while self._peek() == "+":
430 self._read_char("+")
431 nas.append(self._parse_na())
432
433 return RelativeDistinguishedName(nas)
434
435 def _parse_na(self) -> NameAttribute:
436 try:
437 oid_value = self._read_re(self._OID_RE)
438 except ValueError:
439 name = self._read_re(self._DESCR_RE)
440 oid = self._attr_name_overrides.get(
441 name, _NAME_TO_NAMEOID.get(name)
442 )
443 if oid is None:
444 raise ValueError
445 else:
446 oid = ObjectIdentifier(oid_value)
447
448 self._read_char("=")
449 if self._peek() == "#":
450 value = self._read_re(self._HEXSTRING_RE)
451 value = binascii.unhexlify(value[1:]).decode()
452 else:
453 raw_value = self._read_re(self._STRING_RE)
454 value = _unescape_dn_value(raw_value)
455
456 return NameAttribute(oid, value)