Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make extract_patterns along with patterns public #55

Merged
merged 8 commits into from
Jun 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ If breaking changes are needed do be done, they are:

<!-- changelog follows -->

## XX.Y.Z (UNRELEASED)
## [Unreleased](https://github.com/pyca/service-identity/compare/21.1.0...HEAD)

### Backwards-incompatible Changes

Expand All @@ -21,13 +21,17 @@ If breaking changes are needed do be done, they are:
When using such an old pyOpenSSL version, you have to pin *cryptography* yourself to ensure compatibility between them.
Please check out [`contraints/oldest-pyopenssl.txt`](https://github.com/pyca/service-identity/blob/main/constraints/oldest-pyopenssl.txt) to verify what we are testing against.


### Deprecations

*none*


### Changes

*none*
- `service_identity.(cryptography|pyopenssl).extract_patterns()` are now public APIs (FKA `extract_ids()`).
You can use them to extract the patterns from a certificate without verifying anything.
[#55](https://github.com/pyca/service-identity/pull/55)


## 21.1.0 (2021-05-09)
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@

Use this package if:

- you want to verify that a [PyCA cryptography](https://cryptography.io/) certificate is valid for a certain hostname or IP address
- or if you use [pyOpenSSL](https://pypi.org/project/pyOpenSSL/) and don’t want to be [MITM](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)ed.
- you want to **verify** that a [PyCA *cryptography*](https://cryptography.io/) certificate is valid for a certain hostname or IP address,
- or if you use [pyOpenSSL](https://pypi.org/project/pyOpenSSL/) and don’t want to be [**MITM**](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)ed,
- or if you want to **inspect** certificates from either for service IDs.

*service-identity* aspires to give you all the tools you need for verifying whether a certificate is valid for the intended purposes. In the simplest case, this means *host name verification*.
*service-identity* aspires to give you all the tools you need for verifying whether a certificate is valid for the intended purposes.
In the simplest case, this means *host name verification*.
However, *service-identity* implements [RFC 6125](https://datatracker.ietf.org/doc/html/rfc6125.html) fully.


Expand Down
22 changes: 21 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ API

So far, public APIs are only available for host names (:rfc:`6125`) and IP addresses (:rfc:`2818`).
All IDs specified by :rfc:`6125` are already implemented though.
If you'd like to play with them and provide feedback have a look at the ``verify_service_identity`` function in the `_common module <https://github.com/pyca/service-identity/blob/main/src/service_identity/_common.py>`_.
If you'd like to play with them and provide feedback have a look at the ``verify_service_identity`` function in the `common module <https://github.com/pyca/service-identity/blob/main/src/service_identity/common.py>`_.


PyCA cryptography
Expand All @@ -16,6 +16,7 @@ PyCA cryptography

.. autofunction:: verify_certificate_hostname
.. autofunction:: verify_certificate_ip_address
.. autofunction:: extract_patterns


pyOpenSSL
Expand All @@ -31,6 +32,25 @@ pyOpenSSL
:literal:

.. autofunction:: verify_ip_address
.. autofunction:: extract_patterns


Pattern Objects
===============

The following are the objects return by the ``extract_patterns`` functions.
They each carry the attributes that are necessary to match an ID of their type.

.. currentmodule:: service_identity.common

.. autoclass:: DNSPattern
:members:
.. autoclass:: IPAddressPattern
:members:
.. autoclass:: URIPattern
:members:
.. autoclass:: SRVPattern
:members:


Universal Errors and Warnings
Expand Down
30 changes: 21 additions & 9 deletions docs/pyopenssl_example.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
import pprint
import socket
import sys

import idna

from OpenSSL import SSL

from service_identity import VerificationError
from service_identity.pyopenssl import verify_hostname
import service_identity


hostname = sys.argv[1]

ctx = SSL.Context(SSL.SSLv23_METHOD)
ctx.set_verify(SSL.VERIFY_PEER, lambda conn, cert, errno, depth, ok: ok)
ctx.set_default_verify_paths()

hostname = "hynek.me"
conn = SSL.Connection(ctx, socket.socket(socket.AF_INET, socket.SOCK_STREAM))
conn.set_tlsext_host_name(idna.encode(hostname))
conn.connect((hostname, 443))

try:
conn.do_handshake()
verify_hostname(conn, hostname)

print(f"Certificate is valid for {hostname}!")
# Do your super-secure stuff here.
print("Server certificate is valid for the following patterns:\n")
pprint.pprint(
service_identity.pyopenssl.extract_patterns(
conn.get_peer_certificate()
)
)

try:
service_identity.pyopenssl.verify_hostname(conn, hostname)
except service_identity.VerificationError:
print(f"\nPresented certificate is NOT valid for {hostname}.")
finally:
conn.shutdown()
except SSL.Error as e:
print(f"TLS Handshake failed: {e!r}.")
except VerificationError:
print(f"Presented certificate is not valid for {hostname}.")
finally:
conn.shutdown()
conn.close()
3 changes: 2 additions & 1 deletion src/service_identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""


from . import cryptography, pyopenssl
from . import common, cryptography, pyopenssl
from .exceptions import (
CertificateError,
SubjectAltNameWarning,
Expand All @@ -23,6 +23,7 @@
"CertificateError",
"SubjectAltNameWarning",
"VerificationError",
"common",
"cryptography",
"pyopenssl",
]
Expand Down
78 changes: 49 additions & 29 deletions src/service_identity/_common.py → src/service_identity/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
Common verification code.
"""

from __future__ import annotations

import ipaddress
import re

from typing import Union

import attr

from .exceptions import (
Expand Down Expand Up @@ -131,20 +134,19 @@ def _is_ip_address(pattern):
return True


@attr.s(init=False, slots=True)
@attr.s(slots=True)
class DNSPattern:
"""
A DNS pattern as extracted from certificates.
"""

pattern = attr.ib()
#: The pattern.
pattern: bytes = attr.ib()

_RE_LEGAL_CHARS = re.compile(rb"^[a-z0-9\-_.]+$")

def __init__(self, pattern):
"""
:type pattern: `bytes`
"""
@classmethod
def from_bytes(cls, pattern) -> DNSPattern:
if not isinstance(pattern, bytes):
raise TypeError("The DNS pattern must be a bytes string.")

Expand All @@ -153,9 +155,11 @@ def __init__(self, pattern):
if pattern == b"" or _is_ip_address(pattern) or b"\0" in pattern:
raise CertificateError(f"Invalid DNS pattern {pattern!r}.")

self.pattern = pattern.translate(_TRANS_TO_LOWER)
if b"*" in self.pattern:
_validate_pattern(self.pattern)
pattern = pattern.translate(_TRANS_TO_LOWER)
if b"*" in pattern:
_validate_pattern(pattern)

return cls(pattern=pattern)


@attr.s(slots=True)
Expand All @@ -164,53 +168,59 @@ class IPAddressPattern:
An IP address pattern as extracted from certificates.
"""

pattern = attr.ib()
#: The pattern.
pattern: ipaddress.IPv4Address | ipaddress.IPv6Address = attr.ib()

@classmethod
def from_bytes(cls, bs):
def from_bytes(cls, bs: bytes) -> IPAddressPattern:
try:
return cls(pattern=ipaddress.ip_address(bs))
except ValueError:
raise CertificateError(f"Invalid IP address pattern {bs!r}.")


@attr.s(init=False, slots=True)
@attr.s(slots=True)
class URIPattern:
"""
An URI pattern as extracted from certificates.
"""

protocol_pattern = attr.ib()
dns_pattern = attr.ib()
#: The pattern for the protocol part.
protocol_pattern: bytes = attr.ib()
#: The pattern for the DNS part.
dns_pattern: DNSPattern = attr.ib()

def __init__(self, pattern):
"""
:type pattern: `bytes`
"""
@classmethod
def from_bytes(cls, pattern: bytes) -> URIPattern:
if not isinstance(pattern, bytes):
raise TypeError("The URI pattern must be a bytes string.")

pattern = pattern.strip().translate(_TRANS_TO_LOWER)

if b":" not in pattern or b"*" in pattern or _is_ip_address(pattern):
raise CertificateError(f"Invalid URI pattern {pattern!r}.")
self.protocol_pattern, hostname = pattern.split(b":")
self.dns_pattern = DNSPattern(hostname)

protocol_pattern, hostname = pattern.split(b":")

@attr.s(init=False, slots=True)
return cls(
protocol_pattern=protocol_pattern,
dns_pattern=DNSPattern.from_bytes(hostname),
)


@attr.s(slots=True)
class SRVPattern:
"""
An SRV pattern as extracted from certificates.
"""

name_pattern = attr.ib()
dns_pattern = attr.ib()
#: The pattern for the name part.
name_pattern: bytes = attr.ib()
#: The pattern for the DNS part.
dns_pattern: DNSPattern = attr.ib()

def __init__(self, pattern):
"""
:type pattern: `bytes`
"""
@classmethod
def from_bytes(cls, pattern: bytes) -> SRVPattern:
if not isinstance(pattern, bytes):
raise TypeError("The SRV pattern must be a bytes string.")

Expand All @@ -223,9 +233,19 @@ def __init__(self, pattern):
or _is_ip_address(pattern)
):
raise CertificateError(f"Invalid SRV pattern {pattern!r}.")

name, hostname = pattern.split(b".", 1)
self.name_pattern = name[1:]
self.dns_pattern = DNSPattern(hostname)
return cls(
name_pattern=name[1:], dns_pattern=DNSPattern.from_bytes(hostname)
)


CertificatePattern = Union[
SRVPattern, URIPattern, DNSPattern, IPAddressPattern
]
"""
All possible patterns that can be extracted from a certificate.
"""


@attr.s(init=False, slots=True)
Expand Down
27 changes: 16 additions & 11 deletions src/service_identity/cryptography.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
from pyasn1.codec.der.decoder import decode
from pyasn1.type.char import IA5String

from ._common import (
from .common import (
DNS_ID,
CertificateError,
CertificatePattern,
DNSPattern,
IPAddress_ID,
IPAddressPattern,
Expand Down Expand Up @@ -53,7 +54,7 @@ def verify_certificate_hostname(
:returns: ``None``
"""
verify_service_identity(
cert_patterns=extract_ids(certificate),
cert_patterns=extract_patterns(certificate),
obligatory_ids=[DNS_ID(hostname)],
optional_ids=[],
)
Expand Down Expand Up @@ -83,7 +84,7 @@ def verify_certificate_ip_address(
.. versionadded:: 18.1.0
"""
verify_service_identity(
cert_patterns=extract_ids(certificate),
cert_patterns=extract_patterns(certificate),
obligatory_ids=[IPAddress_ID(ip_address)],
optional_ids=[],
)
Expand All @@ -92,17 +93,15 @@ def verify_certificate_ip_address(
ID_ON_DNS_SRV = ObjectIdentifier("1.3.6.1.5.5.7.8.7") # id_on_dnsSRV


def extract_ids(
cert: Certificate,
) -> list[DNSPattern | URIPattern | IPAddressPattern | SRVPattern]:
def extract_patterns(cert: Certificate) -> list[CertificatePattern]:
"""
Extract all valid IDs from a certificate for service verification.
Extract all valid ID patterns from a certificate for service verification.

:param cert: The certificate to be dissected.

:return: List of IDs.

.. removed:: 23.1.0
.. versionchanged:: 23.1.0
``commonName`` is not used as a fallback anymore.
"""
ids = []
Expand All @@ -115,13 +114,13 @@ def extract_ids(
else:
ids.extend(
[
DNSPattern(name.encode("utf-8"))
DNSPattern.from_bytes(name.encode("utf-8"))
for name in ext.value.get_values_for_type(DNSName)
]
)
ids.extend(
[
URIPattern(uri.encode("utf-8"))
URIPattern.from_bytes(uri.encode("utf-8"))
for uri in ext.value.get_values_for_type(
UniformResourceIdentifier
)
Expand All @@ -137,8 +136,14 @@ def extract_ids(
if other.type_id == ID_ON_DNS_SRV:
srv, _ = decode(other.value)
if isinstance(srv, IA5String):
ids.append(SRVPattern(srv.asOctets()))
ids.append(SRVPattern.from_bytes(srv.asOctets()))
else: # pragma: nocover
raise CertificateError("Unexpected certificate content.")

return ids


extract_ids = extract_patterns
"""
Deprecated and never public API. Use :func:`extract_patterns` instead.
"""
Loading