diff --git a/api/kiwi_vpn_api/easyrsa.py b/api/kiwi_vpn_api/easyrsa.py index e15697b..c833338 100644 --- a/api/kiwi_vpn_api/easyrsa.py +++ b/api/kiwi_vpn_api/easyrsa.py @@ -199,10 +199,9 @@ class EasyRSA: def __build_cert( self, - cert_filename: Path, *easyrsa_cmd: str, **easyrsa_env: str, - ) -> x509.Certificate | None: + ) -> None: """ Create an X.509 certificate """ @@ -230,16 +229,35 @@ class EasyRSA: **easyrsa_env, ) - # parse the new certificate - with open( - self.output_directory.joinpath(cert_filename), "rb" - ) as cert_file: + except (subprocess.CalledProcessError): + # certificate couldn't be built + pass + + return None + + def get_certificate( + self, + cert_type: CertificateType | None, + dn: DistinguishedName | None = None, + ) -> x509.Certificate | None: + if cert_type is CertificateType.ca: + cert_filename = self.output_directory.joinpath("ca.crt") + + else: + if dn is None: + dn = DistinguishedName.build() + + cert_filename = (self.output_directory.joinpath("issued") + .joinpath(f"{dn.common_name}.crt")) + + try: + # parse the certificate + with open(cert_filename, "rb") as cert_file: return x509.load_pem_x509_certificate( cert_file.read() ) - except (subprocess.CalledProcessError, FileNotFoundError): - # certificate couldn't be built + except FileNotFoundError: return None def init_pki(self) -> None: @@ -254,14 +272,17 @@ class EasyRSA: Build the CA certificate """ - cert = self.__build_cert( - Path("ca.crt"), + self.__build_cert( "build-ca", EASYRSA_DN="cn_only", EASYRSA_REQ_CN="kiwi-vpn-ca", ) + cert = self.get_certificate( + cert_type=CertificateType.ca, + dn=None, + ) assert cert is not None # # this takes long! @@ -284,9 +305,7 @@ class EasyRSA: or cert_type is CertificateType.server): return None - return self.__build_cert( - Path("issued").joinpath(f"{dn.common_name}.crt"), - + self.__build_cert( f"build-{cert_type}-full", dn.common_name, "nopass", @@ -294,6 +313,11 @@ class EasyRSA: **dn.easyrsa_env, ) + return self.get_certificate( + cert_type=cert_type, + dn=dn, + ) + def renew( self, dn: DistinguishedName | None = None, @@ -305,9 +329,7 @@ class EasyRSA: if dn is None: dn = DistinguishedName.build() - return self.__build_cert( - Path("issued").joinpath(f"{dn.common_name}.crt"), - + self.__build_cert( "renew", dn.common_name, "nopass", @@ -318,6 +340,11 @@ class EasyRSA: **dn.easyrsa_env, ) + return self.get_certificate( + cert_type=None, + dn=dn, + ) + def revoke( self, dn: DistinguishedName | None = None, diff --git a/api/kiwi_vpn_api/routers/_common.py b/api/kiwi_vpn_api/routers/_common.py index 0c2433e..3aba42e 100644 --- a/api/kiwi_vpn_api/routers/_common.py +++ b/api/kiwi_vpn_api/routers/_common.py @@ -7,6 +7,7 @@ from fastapi.security import OAuth2PasswordBearer from ..config import SETTINGS, Config from ..db import Device, User +from ..easyrsa import EASYRSA, CertificateType, EasyRSA oauth2_scheme = OAuth2PasswordBearer( tokenUrl=f"{SETTINGS.api_v1_prefix}/user/authenticate" @@ -35,6 +36,10 @@ class Responses: "description": "Operation not permitted", "content": None, } + NEEDS_PKI = { + "description": "PKI hasn't been initialized", + "content": None, + } ENTRY_ADDED = { "description": "Entry added to database", "content": None, @@ -129,3 +134,18 @@ async def get_device_by_id( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) return device + + +async def get_pki() -> EasyRSA: + """ + Get the EasyRSA object if the CA has been built. + + Status: + + - 425: EasyRSA not initialized + """ + + if EASYRSA.get_certificate(CertificateType.ca) is None: + raise HTTPException(status_code=status.HTTP_425_TOO_EARLY) + + return EASYRSA diff --git a/api/kiwi_vpn_api/routers/device.py b/api/kiwi_vpn_api/routers/device.py index 57b415b..310a403 100644 --- a/api/kiwi_vpn_api/routers/device.py +++ b/api/kiwi_vpn_api/routers/device.py @@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, status from ..db import Device, DeviceCreate, DeviceRead, DeviceStatus, User -from ..easyrsa import EASYRSA, DistinguishedName -from ._common import (Responses, get_current_user, get_device_by_id, +from ..easyrsa import DistinguishedName, EasyRSA +from ._common import (Responses, get_current_user, get_device_by_id, get_pki, get_user_by_name) router = APIRouter(prefix="/device", tags=["device"]) @@ -96,12 +96,14 @@ async def remove_device( status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST, status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS, + status.HTTP_425_TOO_EARLY: Responses.NEEDS_PKI, }, response_model=DeviceRead, ) async def request_certificate_issuance( current_user: User = Depends(get_current_user), device: Device = Depends(get_device_by_id), + pki: EasyRSA = Depends(get_pki), ) -> Device: """ POST ./{device_id}/issue: Request certificate issuance for a device. @@ -124,7 +126,7 @@ async def request_certificate_issuance( # check if we can issue the certificate immediately if current_user.can_issue: - if (certificate := EASYRSA.issue( + if (certificate := pki.issue( dn=DistinguishedName.build(device) )) is not None: device.set_status(DeviceStatus.certified) @@ -144,12 +146,14 @@ async def request_certificate_issuance( status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST, status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS, + status.HTTP_425_TOO_EARLY: Responses.NEEDS_PKI, }, response_model=DeviceRead, ) async def request_certificate_renewal( current_user: User = Depends(get_current_user), device: Device = Depends(get_device_by_id), + pki: EasyRSA = Depends(get_pki), ) -> Device: """ POST ./{device_id}/renew: Request certificate renewal for a device. @@ -172,7 +176,7 @@ async def request_certificate_renewal( # check if we can renew the certificate immediately if current_user.can_renew: - if (certificate := EASYRSA.renew( + if (certificate := pki.renew( dn=DistinguishedName.build(device) )) is not None: device.set_status(DeviceStatus.certified) @@ -192,12 +196,14 @@ async def request_certificate_renewal( status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST, status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS, + status.HTTP_425_TOO_EARLY: Responses.NEEDS_PKI, }, response_model=DeviceRead, ) async def revoke_certificate( current_user: User = Depends(get_current_user), device: Device = Depends(get_device_by_id), + pki: EasyRSA = Depends(get_pki), ) -> Device: """ POST ./{device_id}/revoke: Revoke a device certificate. @@ -217,7 +223,7 @@ async def revoke_certificate( raise HTTPException(status_code=status.HTTP_409_CONFLICT) # revoke the device certificate - EASYRSA.revoke(dn=DistinguishedName.build(device)) + pki.revoke(dn=DistinguishedName.build(device)) # reset the device device.set_status(DeviceStatus.uncertified)