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

Added support for Uzi Server certificate parsing #12

Merged
merged 1 commit into from
Aug 22, 2024
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: 8 additions & 0 deletions tests/certs/generate_mock_certs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,11 @@ openssl req -new -x509 \
-days 3650 \
-subj "/C=NL/O=MockTest Cert/title=physician/SN=doe-11111111/GN=john/CN=john doe-11111111" \
-addext "subjectAltName = otherName:2.5.5.5;IA5STRING:2.16.528.1.1003.1.3.5.5.2-1-11111111-N-90000111-01.015-00000000"

openssl req -x509 \
-nodes \
-keyout dummy.key \
-out mock-022-correct-server-cert.cert \
-days 3650 \
-subj "/C=NL/O=MockTest Cert/CN=test.example.org/serialNumber=1234ABCD" \
-addext "subjectAltName = otherName:2.5.5.5;IA5STRING:2.16.528.1.1003.1.3.5.5.2-1-12345678-S-90000123-00.000-00000000"
64 changes: 64 additions & 0 deletions tests/test_uzi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/python3
import os
import unittest
from uzireader.exceptions import (
UziCertificateException,
)
from uzireader.uzi import Uzi
from uzireader.uziserver import UziServer


class TestUzi(unittest.TestCase):
def setUp(self):
self.succ = "SUCCESS"
self.dir = os.path.dirname(__file__)

def readCert(self, path):
f = open(self.dir + "/certs/" + path, "r")
cert = f.read()
f.close()
return cert

def test_check_valid_cert_022(self):
cert = self.readCert("mock-022-correct-server-cert.cert")
data = Uzi(self.succ, cert)

self.assertEqual("00000000", data["AgbCode"])
self.assertEqual("S", data["CardType"])
self.assertEqual("2.16.528.1.1003.1.3.5.5.2", data["OidCa"])
self.assertEqual("00.000", data["Role"])
self.assertEqual("90000123", data["SubscriberNumber"])
self.assertEqual("12345678", data["UziNumber"])
self.assertEqual("1", data["UziVersion"])
self.assertEqual("test.example.org", data["commonName"])

def test_check_valid_cert_011(self):
cert = self.readCert("mock-011-correct.cert")
data = Uzi(self.succ, cert)

self.assertEqual("00000000", data["AgbCode"])
self.assertEqual("N", data["CardType"])
self.assertEqual("2.16.528.1.1003.1.3.5.5.2", data["OidCa"])
self.assertEqual("30.015", data["Role"])
self.assertEqual("90000111", data["SubscriberNumber"])
self.assertEqual("12345678", data["UziNumber"])
self.assertEqual("1", data["UziVersion"])
self.assertEqual("john doe-12345678", data["commonName"])

def test_check_valid_server_cert(self):
cert = self.readCert("mock-022-correct-server-cert.cert")
data = UziServer(self.succ, cert)

self.assertEqual("00000000", data["AgbCode"])
self.assertEqual("S", data["CardType"])
self.assertEqual("2.16.528.1.1003.1.3.5.5.2", data["OidCa"])
self.assertEqual("00.000", data["Role"])
self.assertEqual("90000123", data["SubscriberNumber"])
self.assertEqual("12345678", data["UziNumber"])
self.assertEqual("1", data["UziVersion"])
self.assertEqual("test.example.org", data["commonName"])

def test_check_invalid_server_cert(self):
cert = self.readCert("mock-011-correct.cert")
with self.assertRaises(UziCertificateException):
data = UziServer(self.succ, cert)
8 changes: 4 additions & 4 deletions tests/test_uzivalidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
UziAllowedTypeException,
UziAllowedRoleException,
)
from uzireader.uzipassuser import UziPassUser
from uzireader.uzi import Uzi
from uzireader.uzipassvalidator import UziPassValidator
from uzireader.consts import UZI_TYPE_CARE_PROVIDER, UZI_ROLE_NURSE, UZI_ROLE_DENTIST

Expand All @@ -29,7 +29,7 @@ def readCert(self, path):

def checkUser(self, path, message=None, types=[], roles=[], exception=UziException):
cert = self.readCert(path)
user = UziPassUser(self.succ, cert)
user = Uzi(self.succ, cert)
with self.assertRaises(exception, msg=message):
validator = UziPassValidator(True, types, roles)
validator.validate(user)
Expand All @@ -41,7 +41,7 @@ def test_empty_user(self):

def test_validate_incorrect_oid(self):
cert = self.readCert("mock-011-correct.cert")
user = UziPassUser(self.succ, cert)
user = Uzi(self.succ, cert)
validator = UziPassValidator(True, [], [])
user["OidCa"] = "1.2.3.4"
with self.assertRaises(
Expand Down Expand Up @@ -83,7 +83,7 @@ def test_not_allowed_role(self):

def test_is_valid(self):
cert = self.readCert("mock-011-correct.cert")
user = UziPassUser(self.succ, cert)
user = Uzi(self.succ, cert)
user["CardType"] = UZI_TYPE_CARE_PROVIDER
user["Role"] = UZI_ROLE_DENTIST
validator = UziPassValidator(True, [UZI_TYPE_CARE_PROVIDER], [UZI_ROLE_DENTIST])
Expand Down
107 changes: 107 additions & 0 deletions uzireader/uzi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/python3
from cryptography import x509
from uzireader.exceptions import (
UziException,
UziExceptionServerConfigError,
UziExceptionClientCertError,
UziCertificateException,
UziCertificateNotUziException,
)
from uzireader.consts import OID_IA5STRING


class Uzi(dict):
"""UziPass dict like object with the following keys:

commonName: The certificate common name
OidCa: OID CA,
UziVersion: UZI Version,
UziNumber: UZI Number,
CardType: Card Type,
SubscriberNumber: Subscriber number,
Role: Role (reference page 89),
AgbCode: ABG Code,

For reference please read
https://www.zorgcsp.nl/documents/RK1%20CPS%20UZI-register%20V10.2%20ENG.pdf
"""

def __init__(self, verify="failed", cert=None):
"""Sets up an Uzi object

Expects the following vars from the webserver env
- SSL_CLIENT_VERIFY
- SSL_CLIENT_CERT
"""
super().__init__()
if verify != "SUCCESS":
raise UziExceptionServerConfigError(
"Webserver client cert check not passed"
)
if not cert:
raise UziExceptionClientCertError("No client certificate presented")
self.cert = x509.load_pem_x509_certificate(bytes(cert.encode("ascii")))
self.update(self._getData())


def _getCommonName(self, rdnSequence):
"""Finds and returns the commonName"""
for sequence in rdnSequence:
for attribute in sequence:
if attribute.oid._name == "commonName":
return attribute.value
raise UziException("No commonName found.")


def _getData(self):
"""Attemps to parse the presented certificate and extract the user info
from it"""
if not self.cert.subject:
raise UziCertificateException("No subject rdnSequence")

commonName = self._getCommonName(self.cert.subject.rdns)

for extension in self.cert.extensions:
if extension.oid._name != "subjectAltName":
continue

for value in extension.value:
if (
type(value) != x509.general_name.OtherName
or value.type_id.dotted_string != OID_IA5STRING
):
continue

subjectAltName = value.value.decode("ascii")

# Reference page 60
#
# [0] OID CA
# [1] UZI Version
# [2] UZI number
# [3] Card type
# [4] Subscriber number
# [5] Role (reference page 89)
# [6] AGB code

data = subjectAltName.split("-")
if len(data) < 6:
raise UziCertificateException("Incorrect SAN found")

if '=' in data[0]:
# To remove the \x16= prefix from the OidCa, for example \x16=2.16.528.1.1007.99.217
data[0] = data[0].split("=", 1)[1]
elif '?' in data[0]:
data[0] = data[0].split("?", 1)[1]

return {
"commonName": commonName,
"OidCa": data[0],
"UziVersion": data[1],
"UziNumber": data[2],
"CardType": data[3],
"SubscriberNumber": data[4],
"Role": data[5],
"AgbCode": data[6],
}
raise UziCertificateNotUziException("No valid UZI data found")
93 changes: 17 additions & 76 deletions uzireader/uzipassuser.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
#!/usr/bin/python3
__author__ = "Jan Klopper <jan@underdark.nl>"
__version__ = "1.1"

from cryptography import x509
from uzireader.exceptions import (
UziException,
Expand All @@ -11,9 +8,10 @@
UziCertificateNotUziException,
)
from uzireader.consts import OID_IA5STRING
from uzireader.uzi import Uzi


class UziPassUser(dict):
class UziPassUser(Uzi):
"""UziPassUser dict like object with the following keys:

givenName: givenName,
Expand All @@ -37,24 +35,27 @@ def __init__(self, verify="failed", cert=None):
- SSL_CLIENT_VERIFY
- SSL_CLIENT_CERT
"""
if verify != "SUCCESS":
raise UziExceptionServerConfigError(
"Webserver client cert check not passed"
)
if not cert:
raise UziExceptionClientCertError("No client certificate presented")
self.cert = x509.load_pem_x509_certificate(bytes(cert.encode("ascii")))
self.update(self._getData())
super().__init__(verify, cert)
if self.get('CardType') not in ["Z", "N", "M"]:
raise UziCertificateException("Uzi CardType is not User (Z/N/M)")

givenName, surName, commonName = None, None, None
try:
givenName, surName = self._getUserNames(self.cert.subject.rdns)
except ValueError:
# fallback to commonName
commonName = self["commonName"]

def _getName(self, rdnSequence):
self["givenName"] = givenName
self["surName"] = surName
self["commonName"] = commonName

def _getUserNames(self, rdnSequence):
"""Finds and returns the surName, and givenName"""
givenName = None
surName = None
for sequence in rdnSequence:
for attribute in sequence:
if attribute.oid._name == "commonName":
return attribute.value

if attribute.oid._name == "surname":
surName = attribute.value

Expand All @@ -64,63 +65,3 @@ def _getName(self, rdnSequence):
if givenName and surName:
return (givenName, surName)
raise UziException("No surname / givenName found.")

def _getData(self):
"""Attemps to parse the presented certificate and extract the user info
from it"""
if not self.cert.subject:
raise UziCertificateException("No subject rdnSequence")

givenName, surName, commonName = None, None, None

try:
givenName, surName = self._getName(self.cert.subject.rdns)
except ValueError:
commonName = self._getName(self.cert.subject.rdns)

for extension in self.cert.extensions:
if extension.oid._name != "subjectAltName":
continue

for value in extension.value:
if (
type(value) != x509.general_name.OtherName
or value.type_id.dotted_string != OID_IA5STRING
):
continue

subjectAltName = value.value.decode("ascii")

# Reference page 60
#
# [0] OID CA
# [1] UZI Version
# [2] UZI number
# [3] Card type
# [4] Subscriber number
# [5] Role (reference page 89)
# [6] AGB code

data = subjectAltName.split("-")
if len(data) < 6:
raise UziCertificateException("Incorrect SAN found")

if '=' in data[0]:
# To remove the \x16= prefix from the OidCa, for example \x16=2.16.528.1.1007.99.217
data[0] = data[0].split("=", 1)[1]
elif '?' in data[0]:
data[0] = data[0].split("?", 1)[1]

return {
"givenName": givenName,
"surName": surName,
"commonName": commonName,
"OidCa": data[0],
"UziVersion": data[1],
"UziNumber": data[2],
"CardType": data[3],
"SubscriberNumber": data[4],
"Role": data[5],
"AgbCode": data[6],
}
raise UziCertificateNotUziException("No valid UZI data found")
6 changes: 3 additions & 3 deletions uzireader/uzipassvalidator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
UziAllowedTypeException,
UziAllowedRoleException,
)
from uzireader.uzipassuser import UziPassUser
from uzireader.uzi import Uzi


class UziPassValidator:
Expand All @@ -17,14 +17,14 @@ def __init__(self, strict_ca: bool, allowed_types: list, allowed_roles: list):
self.allowed_types = allowed_types
self.allowed_roles = allowed_roles

def is_valid(self, user: UziPassUser):
def is_valid(self, user: Uzi):
try:
self.validate(user)
except UziException:
return False
return True

def validate(self, user: UziPassUser):
def validate(self, user: Uzi):
if user is None:
raise UziException("Empty User Provided")
oidca = user["OidCa"]
Expand Down
Loading
Loading