"""
This module implements classes for interacting with different types of smart
cards used within the SCAutolib library. These include
``VirtualCard`` (software-emulated smart cards), ``PhysicalCard`` (real
smart cards in standard readers), and potentially cards connected via
specialized hardware like Removinator. The module provides a
common ``Card`` interface and specialized methods for operations like
inserting/removing cards, and enrolling (uploading keys and certificates).
"""
import json
import re
import time
import shutil
from pathlib import Path
from traceback import format_exc
from SCAutolib import run, logger, TEMPLATES_DIR, LIB_DUMP_CARDS
from SCAutolib.exceptions import SCAutolibException
from SCAutolib.enums import CardType, UserType
[docs]class Card:
"""
An interface class for different types of smart cards.
It defines common attributes and abstract methods that child classes
are expected to implement based on the
specific card type. It also includes a static method
for loading card objects from JSON dump files.
"""
uri: str = None
dump_file: Path = None
type: str = None
_pattern: str = None
[docs] def _set_uri(self):
"""
Sets the URI for the given smart card by matching it from the output
of the ``p11tool --list-token-urls`` command using a regular expression
(``self._pattern``). An exception is raised if
no URI is matched, or if multiple URIs are found.
:return: None
:rtype: None
:raises SCAutolibException: If a matching URI is not found or if
multiple matching URIs are detected.
"""
cmd = ["p11tool", "--list-token-urls"]
out = run(cmd).stdout
urls = re.findall(self._pattern, out)
if len(urls) == 1:
self.uri = urls[0]
logger.info(f"Card URI is set to {self.uri}")
elif len(urls) == 0:
logger.warning("URI not set")
raise SCAutolibException("URI matching expected pattern not found.")
else:
logger.warning("Multiple matching URIs found. URI not set")
raise SCAutolibException("Multiple URIs match expected pattern.")
[docs] def insert(self):
"""
Inserts the smart card.
This is an abstract method that must be implemented by concrete card
type subclasses.
:return: None
:rtype: None
"""
...
[docs] def remove(self):
"""
Removes the smart card.
This is an abstract method that must be implemented by concrete card
type subclasses.
:return: None
:rtype: None
"""
...
[docs] def enroll(self):
"""
Enrolls the card, typically by uploading a certificate and a key onto
it.
This is an abstract method that must be implemented by concrete card
type subclasses.
:return: None
:rtype: None
"""
...
[docs] @staticmethod
def load(json_file):
"""
Loads a ``Card`` object from a specified JSON dump file.
It reads the JSON content, determines the card type, and then
instantiates the appropriate card subclass with the loaded data.
:param json_file: The ``pathlib.Path`` object pointing to the JSON file
containing the serialized card data.
:type json_file: pathlib.Path
:return: An instance of the specific card class loaded with data from
the JSON file.
:rtype: SCAutolib.models.card.Card
:raises SCAutolibException: If an unknown card type is encountered in
the JSON data.
"""
with json_file.open("r") as f:
cnt = json.load(f)
card = None
if cnt["card_type"] == CardType.virtual:
card = VirtualCard(cnt, softhsm2_conf=Path(cnt["softhsm"]))
# card.uri = cnt["uri"]
elif cnt["card_type"] == CardType.physical:
card = PhysicalCard(cnt)
else:
raise SCAutolibException(
f"Unknown card type: {cnt['card_type']}")
return card
[docs]class VirtualCard(Card):
"""
Represents a virtual smart card, which is implemented as a systemd service
on the system. This class provides methods
for managing the lifecycle of a virtual smart card, including its creation,
insertion (starting the service), removal (stopping the service), and
enrollment (uploading keys and certificates to its NSS database via
SoftHSM2). It is designed to be used as a context manager.
"""
_service_name: str = None
_service_location: Path = None
_softhsm2_conf: Path = None
_nssdb: Path = None
_template: Path = Path(TEMPLATES_DIR, "virt_cacard.service")
_pattern = r"(pkcs11:model=PKCS%2315%20emulated;" \
r"manufacturer=Common%20Access%20Card;serial=.*)"
_inserted: bool = False
name: str = None
pin: str = None
cardholder: str = None
CN: str = None
UID: str = None
key: Path = None
cert: Path = None
card_dir: Path = None
card_type: str = None
ca_name: str = None
slot: str = None
user = None
cnf = None
def __init__(self, card_data, softhsm2_conf: Path = None,
card_dir: Path = None, key: Path = None, cert: Path = None):
"""
Initializes a ``VirtualCard`` object. It sets up
card-specific attributes and paths for its files, service, and NSS
database. The card's root directory must
exist prior to calling any methods that interact with it.
:param card_data: A dictionary containing details about the card,
such as ``pin``, ``cardholder``, ``name``, etc.
:type card_data: dict
:param softhsm2_conf: The ``pathlib.Path`` object to the SoftHSM2
configuration file used by this virtual card.
:type softhsm2_conf: pathlib.Path, optional
:param card_dir: The ``pathlib.Path`` object to the card's root
directory where its files will be saved.
:type card_dir: pathlib.Path, optional
:param key: The ``pathlib.Path`` object to the private key file. If
it exists, it will be used with the card.
:type key: pathlib.Path, optional
:param cert: The ``pathlib.Path`` object to the certificate file. If
it exists, it will be used with the card.
:type cert: pathlib.Path, optional
:return: None
:rtype: None
:raises FileNotFoundError: If the specified ``card_dir`` does not exist
upon initialization.
"""
self.name = card_data["name"]
self.pin = card_data["pin"]
self.cardholder = card_data["cardholder"]
self.card_type = card_data["card_type"]
self.CN = card_data["CN"]
self.ca_name = card_data["ca_name"]
self.card_dir = card_dir if card_dir is not None \
else Path(card_data["card_dir"])
if not self.card_dir.exists():
raise FileNotFoundError("Card root directory doesn't exists")
self.dump_file = LIB_DUMP_CARDS.joinpath(f"{self.name}.json")
self.key = key \
if key else self.card_dir.joinpath(f"key-{self.name}.pem")
self.cert = cert \
if cert else self.card_dir.joinpath(f"cert-{self.name}.pem")
self._service_name = self.name
self._service_location = Path(
f"/etc/systemd/system/{self._service_name}.service")
self._nssdb = self.card_dir.joinpath("db")
self._softhsm2_conf = softhsm2_conf if softhsm2_conf \
else Path(self.card_dir, "softhsm2.conf")
def __call__(self, insert: bool):
"""
Allows the ``VirtualCard`` object to be called directly, enabling its
use as part of a context manager.
:param insert: If ``True``, the card's service will be
started (card inserted) upon calling the object.
:type insert: bool
:return: The ``VirtualCard`` instance itself, allowing context manager
entry.
:rtype: SCAutolib.models.card.VirtualCard
"""
if insert:
self.insert()
return self.__enter__()
def __enter__(self):
"""
Enters the context manager for the virtual smart card.
It verifies that the virtual card's systemd service file exists
before proceeding.
:return: The ``VirtualCard`` instance.
:rtype: SCAutolib.models.card.VirtualCard
:raises FileNotFoundError: If the systemd service file for the virtual
card does not exist.
"""
if not self._service_location.exists():
raise FileNotFoundError("Service for virtual sc doesn't exists.")
return self
def __exit__(self, exp_type, exp_value, exp_traceback):
"""
Exits the context manager for the virtual smart card.
If the card was inserted (its service started) upon entering the
context, this method ensures it is removed (service stopped).
Any exceptions raised within the context are logged.
:param exp_type: The type of the exception that caused the context to
be exited, or ``None`` if no exception occurred.
:param exp_value: The exception instance that caused the context to be
exited, or ``None``.
:param exp_traceback: The traceback object associated with the
exception, or ``None``.
:return: None
:rtype: None
"""
if exp_type is not None:
logger.error("Exception in virtual smart card context")
logger.error(format_exc())
if self._inserted:
self.remove()
[docs] def to_dict(self):
"""
Converts the ``VirtualCard`` object's attributes into a dictionary
suitable for JSON serialization. It converts
``pathlib.Path`` objects to string representations for compatibility.
:return: A dictionary representation of the virtual card object's
attributes.
:rtype: dict
"""
return {
"name": self.name,
"pin": self.pin,
"cardholder": self.cardholder,
"card_type": self.card_type,
"CN": self.CN,
"card_dir": str(self.card_dir),
"key": str(self.key),
"cert": str(self.cert),
"uri": self.uri,
"softhsm": str(self._softhsm2_conf),
"ca_name": self.ca_name,
"slot": self.slot
}
@property
def softhsm2_conf(self):
"""
Returns the path to the SoftHSM2 configuration file used by this
virtual card.
:return: A ``pathlib.Path`` object to the SoftHSM2 configuration file.
:rtype: pathlib.Path
"""
return self._softhsm2_conf
@softhsm2_conf.setter
def softhsm2_conf(self, conf: Path):
"""
Sets the path to the SoftHSM2 configuration file for this virtual card.
:param conf: The ``pathlib.Path`` object to the SoftHSM2 configuration
file.
:type conf: pathlib.Path
:return: None
:rtype: None
:raises FileNotFoundError: If the provided configuration file path does
not exist.
"""
if not conf.exists():
raise FileNotFoundError(f"File {conf} doesn't exist")
self._softhsm2_conf = conf
@property
def service_location(self):
"""
Returns the ``pathlib.Path`` object to the systemd service file
location for this virtual smart card.
:return: The service file path.
:rtype: pathlib.Path
"""
return self._service_location
[docs] def insert(self):
"""
Inserts the virtual smart card by starting its corresponding systemd
service. A short delay is included to prevent
errors with fast service restarts.
:return: The ``subprocess.CompletedProcess`` object from the systemctl
command.
:rtype: subprocess.CompletedProcess
"""
cmd = ["systemctl", "start", self._service_name]
out = run(cmd, check=True)
time.sleep(2) # to prevent error with fast restarting of the service
logger.info(f"Smart card {self._service_name} is inserted")
self._inserted = True
return out
[docs] def remove(self):
"""
Removes the virtual smart card by stopping its systemd service.
A short delay is included to prevent errors with fast service restarts.
:return: The ``subprocess.CompletedProcess`` object from the systemctl
command.
:rtype: subprocess.CompletedProcess
"""
cmd = ["systemctl", "stop", self._service_name]
out = run(cmd, check=True)
time.sleep(2) # to prevent error with fast restarting of the service
logger.info(f"Smart card {self._service_name} is removed")
self._inserted = False
return out
[docs] def enroll(self):
"""
Uploads a certificate and private key to the virtual smart card's
internal NSS database via ``pkcs11-tool`` and SoftHSM2.
After enrollment, the card is temporarily inserted to set its URI.
:return: None
:rtype: None
"""
cmd = ["pkcs11-tool", "--module", "libsofthsm2.so", "--slot-index",
'0', "-w", self.key, "-y", "privkey", "--label",
"test_key", "-p", self.pin, "--set-id", "0",
"-d", "0"]
run(cmd, env={"SOFTHSM2_CONF": self._softhsm2_conf})
logger.debug(
f"User key {self.key} is added to virtual smart card")
cmd = ['pkcs11-tool', '--module', 'libsofthsm2.so', '--slot-index', "0",
'-w', self.cert, '-y', 'cert', '-p', self.pin,
'--label', "test_cert", '--set-id', "0", '-d', "0"]
run(cmd, env={"SOFTHSM2_CONF": self._softhsm2_conf})
logger.debug(
f"User certificate {self.cert} is added to virtual smart card")
# To get URI of the card, the card has to be inserted
# Virtual smart card can't be started without a cert and a key uploaded
# to it, so URI can be set only after uploading the cert and a key
with self:
self.insert()
self._set_uri()
[docs] def create(self):
"""
Creates the necessary components for a virtual smart card, including
initializing a SoftHSM2 token, setting up its NSS database, and
creating the corresponding systemd service file.
:return: The ``VirtualCard`` instance.
:rtype: SCAutolib.models.card.VirtualCard
:raises FileNotFoundError: If the SoftHSM2 configuration file is not
found.
"""
if not self._softhsm2_conf.exists():
raise FileNotFoundError("Can't proceed, SoftHSM2 conf not found.")
self.card_dir.joinpath("tokens").mkdir(exist_ok=True)
p11lib = "/usr/lib64/pkcs11/libsofthsm2.so"
# Initialize SoftHSM2 token. An error would be raised if token with same
# label would be created.
cmd = ["softhsm2-util", "--init-token", "--free", "--label",
self.name, "--so-pin", "12345678",
"--pin", self.pin]
run(cmd, env={"SOFTHSM2_CONF": self._softhsm2_conf}, check=True)
logger.debug(
f"SoftHSM token is initialized with label '{self.cardholder}'")
# Initialize NSS db
self._nssdb = self.card_dir.joinpath("db")
self._nssdb.mkdir(exist_ok=True)
run(f"modutil -create -dbdir sql:{self._nssdb} -force", check=True)
logger.debug(f"NSS database is initialized in {self._nssdb}")
out = run(f"modutil -list -dbdir sql:{self._nssdb}")
if "library name: p11-kit-proxy.so" not in out.stdout:
run(["modutil", "-force", "-add", 'SoftHSM PKCS#11', "-dbdir",
f"sql:{self._nssdb}", "-libfile", p11lib])
logger.debug("SoftHSM support is added to NSS database")
# Create systemd service
with self._template.open() as tmp:
with self._service_location.open("w") as f:
f.write(tmp.read().format(username=self.cardholder,
softhsm2_conf=self._softhsm2_conf,
card_dir=self.card_dir))
logger.debug(f"Service is created in {self._service_location}")
run("systemctl daemon-reload")
return self
[docs] def delete(self):
"""
Deletes the virtual card, including its dedicated directory (which
contains certificates, SoftHSM2 token data, and NSS database), and
removes its systemd service file.
:return: None
:rtype: None
"""
shutil.rmtree(self.card_dir)
logger.info(f"Virtual card dir of {self.name} removed")
self._service_location.unlink()
run("systemctl daemon-reload", sleep=3)
logger.debug(f"Service {self._service_name} was removed")
if self.dump_file.exists():
self.dump_file.unlink()
logger.debug(f"Removed {self.dump_file} dump file")
[docs] def gen_csr(self):
"""
Generates a user-specific CSR (Certificate Signing Request) file using
OpenSSL, based on a template CNF file and the user's private key.
This CSR is then sent to a CA for certificate generation.
:return: The ``pathlib.Path`` object to the generated CSR file.
:rtype: pathlib.Path
:raises SCAutolibException: If the private key is not set when
attempting to generate a CSR for an IPA
user.
"""
csr_path = self.card_dir.joinpath(f"csr-{self.cardholder}.csr")
if self.user.user_type == UserType.local:
cmd = ["openssl", "req", "-new", "-nodes", "-key", self.key,
"-reqexts", "req_exts", "-config", self.cnf,
"-out", csr_path]
else:
if not self.key:
raise SCAutolibException("Can't generate CSR because private "
"key is not set")
cmd = ["openssl", "req", "-new", "-days", "365",
"-nodes", "-key", self.key, "-out",
csr_path, "-subj", f"/CN={self.cardholder}"]
run(cmd)
return csr_path
[docs]class PhysicalCard(Card):
"""
Represents a physical smart card.
This class is intended to provide methods for manipulating physical cards,
potentially connected via specialized hardware like a Removinator.
Note: As of the provided code, this class is noted as 'Work In Progress'
and not yet fully tested. Needs to be implemented with removinator.
"""
_inserted: bool = False
name: str = None
pin: str = None
cardholder: str = None
CN: str = None
UID: str = None
expires: str = None
card_type: str = None
ca_name: str = None
slot: str = None
uri: str = None
card_dir: Path = None
def __init__(self, card_data: dict = None, card_dir: Path = None):
"""
Initializes a ``PhysicalCard`` object.
It sets up card attributes based on provided data and verifies the
card's root directory exists.
:param card_data: A dictionary containing details about the physical
card.
:type card_data: dict, optional
:param card_dir: The ``pathlib.Path`` object to the card's root
directory.
:type card_dir: pathlib.Path, optional
:return: None
:rtype: None
:raises FileNotFoundError: If the specified ``card_dir`` does not exist
upon initialization.
"""
self.card_data = card_data
# Not sure we will need following, let's see later
self.name = card_data["name"]
self.pin = card_data["pin"]
self.cardholder = card_data["cardholder"]
self.CN = card_data["CN"]
self.UID = card_data["UID"]
# self.expires = card_data["expires"]
# self.card_type = card_data["card_type"]
# self.ca_name = card_data["ca_name"]
self.slot = card_data["slot"]
self.uri = card_data["uri"]
self.card_dir = card_dir
if not self.card_dir.exists():
raise FileNotFoundError("Card root directory doesn't exists")
self.dump_file = LIB_DUMP_CARDS.joinpath(f"{self.name}.json")
def __call__(self, insert: bool):
"""
Allows the ``PhysicalCard`` object to be called directly, enabling its
use as part of a context manager.
:param insert: If ``True``, the card will be inserted upon calling the
object.
:type insert: bool
:return: The ``PhysicalCard`` instance itself, allowing context manager
entry.
:rtype: SCAutolib.models.card.PhysicalCard
"""
if insert:
self.insert()
return self.__enter__()
def __enter__(self):
"""
Enters the context manager for the physical smart card.
:return: The ``PhysicalCard`` instance.
:rtype: SCAutolib.models.card.PhysicalCard
"""
return self
def __exit__(self, exp_type, exp_value, exp_traceback):
"""
Exits the context manager for the physical smart card.
If the card was marked as inserted, this method ensures it is removed
upon exiting the context. Any exceptions raised
within the context are logged.
:param exp_type: The type of the exception that caused the context to
be exited, or ``None`` if no exception occurred.
:param exp_value: The exception instance that caused the context to be
exited, or ``None``.
:param exp_traceback: The traceback object associated with the
exception, or ``None``.
:return: None
:rtype: None
"""
if exp_type is not None:
logger.error("Exception in physical smart card context")
logger.error(format_exc())
if self._inserted:
self.remove()
[docs] def to_dict(self):
"""
Converts the ``PhysicalCard`` object's attributes into a dictionary
suitable for JSON serialization.
:return: A dictionary representation of the physical card object's
attributes.
:rtype: dict
"""
return self.card_data
@property
def user(self):
"""
Returns the cardholder's username associated with this physical card.
:return: The cardholder's username.
:rtype: str
"""
return self.cardholder
[docs] def insert(self):
"""
Inserts the physical card.
Note: This method is a placeholder and needs to be implemented
to interact with Removinator.
:return: None
:rtype: None
"""
...
[docs] def remove(self):
"""
Removes the physical card.
Note: This method is a placeholder and needs to be implemented
to interact with Removinator.
:return: None
:rtype: None
"""
...