2022-03-30 21:18:54 +00:00
|
|
|
"""
|
|
|
|
Python interface to EasyRSA CA.
|
|
|
|
"""
|
|
|
|
|
2022-03-30 23:41:34 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2022-03-22 00:34:06 +00:00
|
|
|
import subprocess
|
|
|
|
from datetime import datetime
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
from OpenSSL import crypto
|
|
|
|
from passlib import pwd
|
2022-03-30 23:41:34 +00:00
|
|
|
from pydantic import BaseModel
|
2022-03-22 00:34:06 +00:00
|
|
|
|
2022-03-30 22:27:17 +00:00
|
|
|
from .config import CertificateAlgo, Config, Settings
|
2022-03-30 23:59:25 +00:00
|
|
|
from .db import Connection, Device
|
2022-03-30 23:41:34 +00:00
|
|
|
|
|
|
|
|
|
|
|
class DistinguishedName(BaseModel):
|
|
|
|
"""
|
|
|
|
An `X.509 distinguished name` (DN) as specified in RFC 5280
|
|
|
|
"""
|
|
|
|
|
|
|
|
country: str
|
|
|
|
state: str
|
|
|
|
city: str
|
|
|
|
organization: str
|
|
|
|
organizational_unit: str
|
2022-03-31 16:34:36 +00:00
|
|
|
email: str
|
2022-03-30 23:41:34 +00:00
|
|
|
common_name: str
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def build(cls, device: Device | None = None) -> DistinguishedName:
|
|
|
|
"""
|
|
|
|
Create a DN from the current config and an optional device
|
|
|
|
"""
|
|
|
|
|
|
|
|
# extract server DN config
|
|
|
|
server_dn = Config._.server_dn
|
|
|
|
result = cls(
|
|
|
|
country=server_dn.country.value,
|
|
|
|
state=server_dn.state.value,
|
|
|
|
city=server_dn.city.value,
|
|
|
|
organization=server_dn.organization.value,
|
|
|
|
organizational_unit=server_dn.organizational_unit.value,
|
|
|
|
email=server_dn.email.value,
|
|
|
|
common_name=server_dn.common_name,
|
|
|
|
)
|
|
|
|
|
|
|
|
# no device specified -> done
|
|
|
|
if device is None:
|
|
|
|
return result
|
|
|
|
|
|
|
|
# don't override locked or empty fields
|
|
|
|
if not (server_dn.country.locked
|
|
|
|
or device.owner.country is None):
|
|
|
|
result.country = device.owner.country
|
|
|
|
|
|
|
|
if not (server_dn.state.locked
|
|
|
|
or device.owner.state is None):
|
|
|
|
result.state = device.owner.state
|
|
|
|
|
|
|
|
if not (server_dn.city.locked
|
|
|
|
or device.owner.city is None):
|
|
|
|
result.city = device.owner.city
|
|
|
|
|
|
|
|
if not (server_dn.organization.locked
|
|
|
|
or device.owner.organization is None):
|
|
|
|
result.organization = device.owner.organization
|
|
|
|
|
|
|
|
if not (server_dn.organizational_unit.locked
|
|
|
|
or device.owner.organizational_unit is None):
|
|
|
|
result.organizational_unit = device.owner.organizational_unit
|
|
|
|
|
|
|
|
# definitely use derived email and common_name
|
|
|
|
result.email = device.owner.email
|
|
|
|
result.common_name = f"{device.owner.name}_{device.name}"
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
@property
|
2022-03-31 16:32:07 +00:00
|
|
|
def easyrsa_args(self) -> list[str]:
|
2022-03-30 23:41:34 +00:00
|
|
|
"""
|
|
|
|
Pass this DN as arguments to easyrsa
|
|
|
|
"""
|
|
|
|
|
2022-03-31 16:32:07 +00:00
|
|
|
return [
|
2022-03-30 23:41:34 +00:00
|
|
|
"--dn-mode=org",
|
|
|
|
|
|
|
|
f"--req-c={self.country}",
|
|
|
|
f"--req-st={self.state}",
|
|
|
|
f"--req-city={self.city}",
|
|
|
|
f"--req-org={self.organization}",
|
|
|
|
f"--req-ou={self.organizational_unit}",
|
|
|
|
f"--req-email={self.email}",
|
|
|
|
f"--req-cn={self.common_name}",
|
2022-03-31 16:32:07 +00:00
|
|
|
]
|
2022-03-22 00:34:06 +00:00
|
|
|
|
|
|
|
|
2022-03-30 21:18:54 +00:00
|
|
|
class EasyRSA:
|
|
|
|
"""
|
|
|
|
Represents an EasyRSA PKI.
|
|
|
|
"""
|
|
|
|
|
|
|
|
@property
|
2022-03-30 23:41:34 +00:00
|
|
|
def output_directory(self) -> Path:
|
|
|
|
"""
|
|
|
|
Where certificates are stored
|
|
|
|
"""
|
|
|
|
|
2022-03-30 21:18:54 +00:00
|
|
|
return Settings._.data_dir.joinpath("pki")
|
|
|
|
|
|
|
|
@property
|
|
|
|
def ca_password(self) -> str:
|
2022-03-30 23:41:34 +00:00
|
|
|
"""
|
|
|
|
Get CA password from config, or generate a new one
|
|
|
|
"""
|
|
|
|
|
2022-03-30 21:18:54 +00:00
|
|
|
config = Config._
|
|
|
|
|
|
|
|
if (ca_password := config.crypto.ca_password) is None:
|
|
|
|
ca_password = pwd.genword(
|
|
|
|
length=32,
|
|
|
|
charset="ascii_62",
|
|
|
|
)
|
2022-03-22 00:34:06 +00:00
|
|
|
|
2022-03-30 21:18:54 +00:00
|
|
|
config.crypto.ca_password = ca_password
|
|
|
|
config.save()
|
2022-03-22 00:34:06 +00:00
|
|
|
|
2022-03-30 21:18:54 +00:00
|
|
|
return config.crypto.ca_password
|
2022-03-22 00:34:06 +00:00
|
|
|
|
|
|
|
def __easyrsa(
|
|
|
|
self,
|
|
|
|
*easyrsa_args: str,
|
|
|
|
) -> subprocess.CompletedProcess:
|
2022-03-30 23:41:34 +00:00
|
|
|
"""
|
|
|
|
Call the `easyrsa` executable
|
|
|
|
"""
|
|
|
|
|
2022-03-22 00:34:06 +00:00
|
|
|
return subprocess.run(
|
|
|
|
[
|
|
|
|
"easyrsa", "--batch",
|
2022-03-30 23:41:34 +00:00
|
|
|
f"--pki-dir={self.output_directory}",
|
2022-03-22 00:34:06 +00:00
|
|
|
*easyrsa_args,
|
|
|
|
],
|
|
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
|
|
check=True,
|
|
|
|
)
|
|
|
|
|
|
|
|
def __build_cert(
|
|
|
|
self,
|
|
|
|
cert_filename: Path,
|
2022-03-30 22:27:17 +00:00
|
|
|
expiry_days: int | None,
|
2022-03-22 00:34:06 +00:00
|
|
|
*easyrsa_args: str,
|
|
|
|
) -> crypto.X509:
|
2022-03-30 23:41:34 +00:00
|
|
|
"""
|
|
|
|
Create an X.509 certificate
|
|
|
|
"""
|
|
|
|
|
2022-03-30 22:27:17 +00:00
|
|
|
config = Config._
|
|
|
|
|
2022-03-31 16:32:07 +00:00
|
|
|
extra_args: list[str] = [
|
2022-03-30 23:41:34 +00:00
|
|
|
f"--passout=pass:{self.ca_password}",
|
|
|
|
f"--passin=pass:{self.ca_password}",
|
2022-03-31 16:32:07 +00:00
|
|
|
]
|
2022-03-30 22:27:17 +00:00
|
|
|
|
|
|
|
if expiry_days is not None:
|
2022-03-31 16:32:07 +00:00
|
|
|
extra_args += [f"--days={expiry_days}"]
|
2022-03-30 22:27:17 +00:00
|
|
|
|
|
|
|
if (algo := config.crypto.cert_algo) is not None:
|
|
|
|
if algo is CertificateAlgo.rsa2048:
|
|
|
|
extra_args += ("--use-algo=rsa", "--keysize=2048")
|
|
|
|
|
|
|
|
elif algo is CertificateAlgo.rsa4096:
|
|
|
|
extra_args += ("--use-algo=rsa", "--keysize=4096")
|
|
|
|
|
|
|
|
elif algo is CertificateAlgo.secp256r1:
|
|
|
|
extra_args += ("--use-algo=ec", "--curve=secp256r1")
|
|
|
|
|
|
|
|
elif algo is CertificateAlgo.secp384r1:
|
|
|
|
extra_args += ("--use-algo=ec", "--curve=secp384r1")
|
|
|
|
|
|
|
|
elif algo is CertificateAlgo.ed25519:
|
|
|
|
extra_args += ("--use-algo=ed", "--curve=ed25519")
|
|
|
|
|
|
|
|
else:
|
|
|
|
raise ValueError(f"Unexpected algorithm: {algo}")
|
|
|
|
|
|
|
|
self.__easyrsa(
|
|
|
|
*extra_args,
|
|
|
|
*easyrsa_args
|
|
|
|
)
|
2022-03-22 00:34:06 +00:00
|
|
|
|
|
|
|
with open(
|
2022-03-31 16:32:07 +00:00
|
|
|
self.output_directory.joinpath(cert_filename), "rb"
|
2022-03-22 00:34:06 +00:00
|
|
|
) as cert_file:
|
|
|
|
return crypto.load_certificate(
|
|
|
|
crypto.FILETYPE_PEM, cert_file.read()
|
|
|
|
)
|
|
|
|
|
2022-03-31 16:32:07 +00:00
|
|
|
def init_pki(self) -> None:
|
2022-03-30 23:41:34 +00:00
|
|
|
"""
|
|
|
|
Clean the working directory
|
|
|
|
"""
|
|
|
|
|
2022-03-22 00:34:06 +00:00
|
|
|
self.__easyrsa("init-pki")
|
|
|
|
|
2022-03-30 22:27:17 +00:00
|
|
|
def build_ca(self) -> crypto.X509:
|
2022-03-30 23:41:34 +00:00
|
|
|
"""
|
|
|
|
Build the CA certificate
|
|
|
|
"""
|
2022-03-30 21:18:54 +00:00
|
|
|
|
2022-03-24 23:27:35 +00:00
|
|
|
cert = self.__build_cert(
|
2022-03-22 00:34:06 +00:00
|
|
|
Path("ca.crt"),
|
2022-03-30 23:41:34 +00:00
|
|
|
Config._.crypto.ca_expiry_days,
|
2022-03-22 00:34:06 +00:00
|
|
|
|
2022-03-30 23:41:34 +00:00
|
|
|
"--req-cn=kiwi-vpn-ca",
|
2022-03-22 00:57:09 +00:00
|
|
|
|
2022-03-22 00:34:06 +00:00
|
|
|
"build-ca",
|
|
|
|
)
|
|
|
|
|
2022-03-30 23:41:34 +00:00
|
|
|
# # this takes long!
|
2022-03-30 21:18:54 +00:00
|
|
|
# self.__easyrsa("gen-dh")
|
2022-03-24 23:27:35 +00:00
|
|
|
return cert
|
|
|
|
|
2022-03-22 00:34:06 +00:00
|
|
|
def issue(
|
|
|
|
self,
|
2022-03-30 22:27:17 +00:00
|
|
|
cert_type: str = "client",
|
2022-03-30 23:41:34 +00:00
|
|
|
dn: DistinguishedName = DistinguishedName.build(),
|
2022-03-22 00:34:06 +00:00
|
|
|
) -> crypto.X509:
|
2022-03-30 23:41:34 +00:00
|
|
|
"""
|
|
|
|
Issue a client or server certificate
|
|
|
|
"""
|
2022-03-30 21:18:54 +00:00
|
|
|
|
2022-03-22 00:34:06 +00:00
|
|
|
return self.__build_cert(
|
2022-03-30 23:41:34 +00:00
|
|
|
Path(f"issued/{dn.common_name}.crt"),
|
|
|
|
Config._.crypto.cert_expiry_days,
|
2022-03-22 00:34:06 +00:00
|
|
|
|
2022-03-30 23:41:34 +00:00
|
|
|
*dn.easyrsa_args,
|
2022-03-22 00:34:06 +00:00
|
|
|
|
|
|
|
f"build-{cert_type}-full",
|
2022-03-30 23:41:34 +00:00
|
|
|
dn.common_name,
|
2022-03-22 00:34:06 +00:00
|
|
|
"nopass",
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2022-03-30 23:41:34 +00:00
|
|
|
# some basic test
|
|
|
|
|
2022-03-22 00:34:06 +00:00
|
|
|
if __name__ == "__main__":
|
2022-03-30 21:18:54 +00:00
|
|
|
easy_rsa = EasyRSA()
|
2022-03-22 00:57:09 +00:00
|
|
|
easy_rsa.init_pki()
|
2022-03-22 00:34:06 +00:00
|
|
|
|
2022-03-30 21:18:54 +00:00
|
|
|
ca = easy_rsa.build_ca()
|
2022-03-30 23:41:34 +00:00
|
|
|
server = easy_rsa.issue("server")
|
2022-03-30 23:59:25 +00:00
|
|
|
client = None
|
|
|
|
|
|
|
|
# check if configured
|
2022-03-31 16:32:07 +00:00
|
|
|
if (current_config := Config.load()) is not None:
|
2022-03-30 23:59:25 +00:00
|
|
|
# connect to database
|
|
|
|
Connection.connect(current_config.db.uri)
|
|
|
|
|
|
|
|
if (device := Device.get(1)) is not None:
|
|
|
|
with Connection.session as db:
|
|
|
|
db.add(device)
|
|
|
|
dn = DistinguishedName.build(device)
|
|
|
|
|
|
|
|
client = easy_rsa.issue("client", dn)
|
2022-03-22 00:34:06 +00:00
|
|
|
|
|
|
|
date_format, encoding = "%Y%m%d%H%M%SZ", "ascii"
|
2022-03-22 00:57:09 +00:00
|
|
|
|
2022-03-30 23:59:25 +00:00
|
|
|
for cert in (ca, server, client):
|
|
|
|
if cert is not None:
|
|
|
|
print(cert.get_subject().CN)
|
|
|
|
print(cert.get_signature_algorithm().decode(encoding))
|
2022-03-31 16:32:07 +00:00
|
|
|
|
|
|
|
assert (na := cert.get_notAfter()) is not None
|
|
|
|
print(datetime.strptime(na.decode(encoding), date_format))
|