Add transient NPM certificate material
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class CertificateMaterial:
|
||||
"""
|
||||
Secret-bearing certificate material for transient use only.
|
||||
|
||||
Instances must never be persisted, placed in generic reconciliation
|
||||
objects, or included in logs.
|
||||
"""
|
||||
|
||||
certificate: bytes
|
||||
certificate_key: bytes
|
||||
intermediate_certificate: bytes | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.certificate, bytes) or not self.certificate:
|
||||
raise ValueError(
|
||||
"Certificate material requires certificate bytes"
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(self.certificate_key, bytes)
|
||||
or not self.certificate_key
|
||||
):
|
||||
raise ValueError(
|
||||
"Certificate material requires certificate-key bytes"
|
||||
)
|
||||
|
||||
if self.intermediate_certificate is not None and (
|
||||
not isinstance(self.intermediate_certificate, bytes)
|
||||
or not self.intermediate_certificate
|
||||
):
|
||||
raise ValueError(
|
||||
"Intermediate certificate must be non-empty bytes"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
intermediate = (
|
||||
"<redacted>"
|
||||
if self.intermediate_certificate is not None
|
||||
else "None"
|
||||
)
|
||||
return (
|
||||
"CertificateMaterial("
|
||||
"certificate=<redacted>, "
|
||||
"certificate_key=<redacted>, "
|
||||
f"intermediate_certificate={intermediate}"
|
||||
")"
|
||||
)
|
||||
+138
-7
@@ -1,10 +1,14 @@
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
from .adapters import Endpoint
|
||||
from .certificates.material import CertificateMaterial
|
||||
|
||||
|
||||
class NpmClient:
|
||||
@@ -236,6 +240,138 @@ class NpmClient:
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _pem_bytes(value: Any, label: str) -> bytes:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RuntimeError(
|
||||
f"NPM certificate is missing {label}"
|
||||
)
|
||||
return value.encode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _zip_pem_member(
|
||||
archive: ZipFile,
|
||||
prefix: str,
|
||||
) -> bytes:
|
||||
matches = []
|
||||
|
||||
for info in archive.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
|
||||
basename = PurePosixPath(info.filename).name
|
||||
|
||||
if not basename.endswith(".pem"):
|
||||
continue
|
||||
|
||||
stem = basename[:-4]
|
||||
|
||||
if not stem.startswith(prefix):
|
||||
continue
|
||||
|
||||
suffix = stem[len(prefix):]
|
||||
|
||||
if not suffix.isdigit():
|
||||
continue
|
||||
|
||||
matches.append(info)
|
||||
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(
|
||||
"NPM certificate download must contain exactly one "
|
||||
f"{prefix}N.pem member"
|
||||
)
|
||||
|
||||
data = archive.read(matches[0])
|
||||
|
||||
if not data:
|
||||
raise RuntimeError(
|
||||
f"NPM certificate download {prefix} member is empty"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def certificate_material(
|
||||
self,
|
||||
local_id: str | int,
|
||||
) -> CertificateMaterial:
|
||||
certificate_id = self._local_id(
|
||||
local_id,
|
||||
"certificate",
|
||||
)
|
||||
|
||||
detail = self.certificate(certificate_id)
|
||||
provider = str(
|
||||
detail.get("provider") or ""
|
||||
).strip().lower()
|
||||
|
||||
if provider == "other":
|
||||
meta = detail.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
raise RuntimeError(
|
||||
"NPM custom certificate has invalid metadata"
|
||||
)
|
||||
|
||||
intermediate = meta.get(
|
||||
"intermediate_certificate"
|
||||
)
|
||||
|
||||
return CertificateMaterial(
|
||||
certificate=self._pem_bytes(
|
||||
meta.get("certificate"),
|
||||
"certificate",
|
||||
),
|
||||
certificate_key=self._pem_bytes(
|
||||
meta.get("certificate_key"),
|
||||
"certificate_key",
|
||||
),
|
||||
intermediate_certificate=(
|
||||
None
|
||||
if intermediate in (None, "")
|
||||
else self._pem_bytes(
|
||||
intermediate,
|
||||
"intermediate_certificate",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if provider == "letsencrypt":
|
||||
response = self.request(
|
||||
"GET",
|
||||
(
|
||||
"/nginx/certificates/"
|
||||
f"{certificate_id}/download"
|
||||
),
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
try:
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
return CertificateMaterial(
|
||||
certificate=self._zip_pem_member(
|
||||
archive,
|
||||
"cert",
|
||||
),
|
||||
certificate_key=self._zip_pem_member(
|
||||
archive,
|
||||
"privkey",
|
||||
),
|
||||
intermediate_certificate=(
|
||||
self._zip_pem_member(
|
||||
archive,
|
||||
"chain",
|
||||
)
|
||||
),
|
||||
)
|
||||
except BadZipFile as exc:
|
||||
raise RuntimeError(
|
||||
"NPM certificate download was not a valid ZIP archive"
|
||||
) from exc
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unsupported NPM certificate provider: {provider!r}"
|
||||
)
|
||||
|
||||
def create_certificate(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
@@ -258,7 +394,7 @@ class NpmClient:
|
||||
certificate: bytes,
|
||||
certificate_key: bytes,
|
||||
intermediate_certificate: bytes | None = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> None:
|
||||
certificate_id = self._local_id(
|
||||
local_id,
|
||||
"certificate",
|
||||
@@ -290,14 +426,9 @@ class NpmClient:
|
||||
intermediate_certificate,
|
||||
)
|
||||
|
||||
response = self.request(
|
||||
self.request(
|
||||
"POST",
|
||||
f"/nginx/certificates/{certificate_id}/upload",
|
||||
expected_status=200,
|
||||
files=files,
|
||||
)
|
||||
|
||||
return self._json_object(
|
||||
response,
|
||||
"certificate upload",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user