9 Commits
9 changed files with 3069 additions and 818 deletions
+33 -24
View File
@@ -88,40 +88,49 @@ graph TB
--- ---
## mTLS Security, Dynamic PKI & Licensing ## mTLS Security, Dynamic PKI & Fleet Licensing
### 1. TLS 1.3 Mutual Authentication (mTLS) ### 1. TLS 1.3 Mutual Authentication (mTLS)
- **Port 9443**: Ingestion occurs exclusively over TLS 1.3 sockets with `ssl.CERT_REQUIRED`. - **Port 9443**: Ingestion occurs exclusively over TLS 1.3 sockets with `ssl.CERT_REQUIRED`.
- Both the hub and edge clients verify each other's certificates: - Both the hub and edge clients verify each other's certificates:
- Client verifies server certificate against `ca.crt`. - Client verifies server certificate against `ca.crt`.
- Server verifies client certificate against the Root CA. - Server verifies client certificate against the Subordinate/Root CA.
- **Client CN Identification**: In the TLS handshake, the server extracts the `commonName` attribute (`client_id`), validates that the client is marked `active` in the `clients` table, updates the `last_seen` timestamp, and drops unregistered or revoked certificates immediately. - **Client CN Identification & Revocation**: In the TLS handshake, the server extracts the `commonName` attribute (`client_id`), validates that the client is marked `active` in the `clients` table, updates the `last_seen` timestamp, and drops unregistered or revoked certificates immediately.
### 2. Dynamic PKI Hub Engine (`src/server_enrollment.py`) ### 2. Master License Authority (`logar.eibl.tech`) & Seat-Bearing Subordinate CA
- **Root CA**: On first run, `Server.py` creates a self-signed Root CA (`ca.crt` / `ca.key`) valid for 10 years. - **Master Root Authority**: Standalone PKI service ([`src/License_Authority.py`](file:///u:/max/Desktop/LOGAR/src/License_Authority.py)) maintained at `logar.eibl.tech`.
- **Server TLS Certificate**: Generated automatically with Subject Alternative Names (SANs) for `localhost`, `127.0.0.1`, server IP, and hostnames. - **Custom Enterprise X.509 OIDs**:
- **Authority Key Identifiers**: Full compliance with OpenSSL 3.x and Python 3.12–3.14 via `SubjectKeyIdentifier` and `AuthorityKeyIdentifier` extensions. - `OID_LOGAR_SEATS (1.3.6.1.4.1.59999.1.1)`: Embeds the exact licensed seat quota directly inside the signed Subordinate CA certificate.
- **Dynamic Client Certificates**: RSA-2048 keys and X.509 client certificates are issued on the fly via the enrollment API. - `OID_LOGAR_LICENSE_ID (1.3.6.1.4.1.59999.1.2)`: Uniquely tracks enterprise license entitlement.
- `OID_LOGAR_ISSUER_URL (1.3.6.1.4.1.59999.1.3)`: Embedded endpoint coordinate (`https://logar.eibl.tech`).
- **Exportable `.lic` Request Files**:
- The LOGAR server generates its Subordinate CA private key (`certs/ca.key`), Certificate Signing Request (`certs/ca.csr`), and packages them into an exportable `logar_server.lic` file containing the CSR, server fingerprint, and license authority coordinate.
- Exportable directly from the WebUI or via CLI (`python src/Server.py --export-lic server.lic`).
- **One-Time Pass & Zero-Touch mTLS Renewals**:
- The signed CA certificate only needs to be imported **once** into the LOGAR Server (via WebUI or CLI `--import-ca`).
- Once imported, the server hub's background watchdog monitors `ca.crt` expiration (< 30 days).
- Renewals are performed **automatically over mTLS**: the LOGAR Server authenticates directly against `https://logar.eibl.tech/api/license/renew` using its existing active certificate and key, receives the renewed Subordinate CA certificate preserving its seat quota, and reloads its in-flight `ssl.SSLContext` without dropping client connections or restarting services.
### 3. Seat Accounting & Licensing ### 3. Integrated Fleet Licensing & WebUI Dashboard
- Stored in SQLite table `license_config`: - **WebUI URL**: Accessible at `http://<server_host>:8443/webui` (or `/`).
- `max_seats`: Maximum concurrent active client licenses (default: 10). - **Visual Seat Quota Meter**: Real-time progress bar displaying active forwarders vs. allocated seat limit.
- `enrollment_secret`: Cryptographic secret required for initial client enrollment. - **Licensing Actions**:
- Stored in SQLite table `clients`: - 1-click **Export .lic Request** file download.
- `client_id`: Unique client identifier (machine GUID or hardware hash). - Interactive **View & Copy CA CSR** modal.
- `hostname`, `os_type`, `cert_fingerprint`, `status` (`active` / `revoked`), `first_seen`, `last_seen`. - Drag-and-drop or paste **Import Signed CA Certificate** modal.
- When a new client enrolls: - **Renew via mTLS Now** button for immediate upstream renewal.
- If `active_seats >= max_seats`, the hub rejects registration with `HTTP 403 (License seat limit reached)`. - **Seat Management & Revocation Table**:
- Existing registered clients can re-enroll / renew seamlessly without consuming additional seats. - Lists all connected Windows and Linux edge forwarders.
- **Revoke Seat**: Instantly drops and blocks the client at the TLS socket handshake layer.
- **Reactivate Seat**: Restores the client to active status if within seat quota limits.
### 4. In-Flight Certificate Watchdog & Automated Self-Healing Renewal ### 4. Cloud-Side In-Flight Certificate Watchdog
- **Continuous Hub PKI Watchdog**: - **Continuous Hub PKI Watchdog**:
- The server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, running every 12 hours) alongside startup checks. - The server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, running every 12 hours).
- The hub automatically inspects expiration dates of both the Root CA (`ca.crt`) and the Server TLS certificate (`server.crt`). - Automatically renews Subordinate CA certificates over mTLS with `logar.eibl.tech` and Server TLS certificates in-flight, dynamically reloading the active `ssl.SSLContext`.
- If either certificate is within 30 days of expiration, the server regenerates certificates (backing up previous keys as `ca.crt.<timestamp>.bak`) and dynamically reloads its active `ssl.SSLContext` in memory without dropping socket connections or restarting the service.
- **Client Proactive Check & Reactive Self-Healing**: - **Client Proactive Check & Reactive Self-Healing**:
- **Proactive Renewal**: Edge clients inspect `client.crt` before every run cycle. If the certificate expires in less than 30 days, it automatically contacts `/api/client/enroll` to renew its certificate. - **Proactive Renewal**: Edge clients inspect `client.crt` before every run cycle. If expiring within 30 days, they contact `/api/client/enroll` to renew.
- **Reactive Self-Healing**: If the server hub Root CA rotates or a handshake fails with `ssl.SSLError` / `SSLCertVerificationError`, edge clients catch the verification exception, re-bootstrap certificate enrollment against the hub, and re-establish the connection cleanly without human intervention. - **Reactive Self-Healing**: If the server CA rotates or a handshake error occurs, edge clients catch the exception, re-bootstrap enrollment against the hub, and re-establish streaming cleanly.
--- ---
+1
View File
@@ -15,6 +15,7 @@ Standalone compiled binary and automated systemd service distribution for Linux
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours. - **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours.
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates. - **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates.
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run. - **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
--- ---
+34 -6
View File
@@ -10,11 +10,12 @@ Standalone compiled binary and automated systemd service distribution for Linux
### Key Architecture & Capabilities ### Key Architecture & Capabilities
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Linux ELF binary (`Server.bin`). No Python runtime, pip dependencies, or GnuPG binaries are required on the host system. - **Pre-compiled & Dependency-Free**: Ships as a standalone native Linux ELF binary (`Server.bin`). No Python runtime, pip dependencies, or GnuPG binaries are required on the host system.
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Root CA and verifies active licensing in SQLite. - **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Subordinate CA and verifies active licensing in SQLite.
- **Dynamic PKI & License Accounting**: Built-in Root CA generates server TLS certificates with SANs and dynamically signs client certificates via `POST /api/client/enroll` while enforcing seat limits (`max_seats`). - **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
- **In-Flight Certificate Watchdog & Dynamic Reloading**: Continuously monitors Root CA (`ca.crt`) and Server TLS certificate (`server.crt`) validity in the background (every 12 hours). When nearing expiration (< 30 days), certificates are automatically regenerated with timestamped backups, and active `ssl.SSLContext` structures are reloaded dynamically without dropping socket connections or restarting the systemd service. - **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window. - **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window.
- **Embedded Hermes Reporting & Management API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`. - **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`). - **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
--- ---
@@ -99,7 +100,33 @@ The output file contains the server coordinates, enrollment secret, and fingerpr
--- ---
## 4. Manual Systemd Service Management ## 4. Fleet Licensing, .lic Files & WebUI Dashboard
### Accessing the WebUI
Open your browser and navigate to:
```
http://<SERVER_IP>:8443/webui
```
The WebUI provides:
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
3. **Import Signed CA Certificate**: Modal to paste or upload the signed `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`.
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
### CLI Licensing Utilities
Export a `.lic` request file via CLI:
```bash
./Server.bin --export-lic server.lic
```
Import a signed CA certificate via CLI:
```bash
./Server.bin --import-ca ca.crt
```
---
## 5. Manual Systemd Service Management
Check service status: Check service status:
```bash ```bash
@@ -113,7 +140,7 @@ sudo journalctl -u logar-server.service -f -n 50
--- ---
## 5. Uninstallation & Removal ## 6. Uninstallation & Removal
```bash ```bash
sudo systemctl disable --now logar-server.service sudo systemctl disable --now logar-server.service
@@ -121,3 +148,4 @@ sudo rm -f /etc/systemd/system/logar-server.service
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo rm -rf /opt/logar-server /etc/logar /var/log/logar sudo rm -rf /opt/logar-server /etc/logar /var/log/logar
``` ```
+1
View File
@@ -15,6 +15,7 @@ Standalone compiled executable and installer distribution for Windows Server and
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours. - **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours.
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates. - **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates.
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run. - **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
--- ---
+34 -6
View File
@@ -10,11 +10,12 @@ Standalone compiled executable and installer distribution for Windows Server env
### Key Architecture & Capabilities ### Key Architecture & Capabilities
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`) or full installer (`LOGAR-Server-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server. - **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`) or full installer (`LOGAR-Server-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Root CA and verifies active licensing in SQLite. - **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Subordinate CA and verifies active licensing in SQLite.
- **Dynamic PKI & License Accounting**: Built-in Root CA generates server TLS certificates with SANs and dynamically signs client certificates via `POST /api/client/enroll` while enforcing seat limits (`max_seats`). - **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
- **In-Flight Certificate Watchdog & Dynamic Reloading**: Continuously monitors Root CA (`ca.crt`) and Server TLS certificate (`server.crt`) validity in the background (every 12 hours). When nearing expiration (< 30 days), certificates are automatically regenerated with timestamped backups, and active `ssl.SSLContext` structures are reloaded dynamically without dropping socket connections or restarting the Windows service. - **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct transmission cycles within a rolling 12-hour evaluation window. - **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct transmission cycles within a rolling 12-hour evaluation window.
- **Embedded Hermes Reporting & Management API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`. - **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`). - **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
--- ---
@@ -99,7 +100,33 @@ The output file contains the server coordinates, enrollment secret, and fingerpr
--- ---
## 4. Manual Windows Service Setup (via NSSM) ## 4. Fleet Licensing, .lic Files & WebUI Dashboard
### Accessing the WebUI
Open your browser and navigate to:
```
http://localhost:8443/webui
```
The WebUI provides:
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
3. **Import Signed CA Certificate**: Modal to paste or upload the signed `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`.
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
### CLI Licensing Utilities
Export a `.lic` request file via CLI:
```powershell
.\Server.exe --export-lic C:\LOGAR-Server\server.lic
```
Import a signed CA certificate via CLI:
```powershell
.\Server.exe --import-ca C:\LOGAR-Server\ca.crt
```
---
## 5. Manual Windows Service Setup (via NSSM)
```powershell ```powershell
.\nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe" .\nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe"
@@ -111,7 +138,7 @@ The output file contains the server coordinates, enrollment secret, and fingerpr
--- ---
## 5. Uninstallation ## 6. Uninstallation
If installed via the Inno Setup installer, use **Windows Add/Remove Programs**. If installed via the Inno Setup installer, use **Windows Add/Remove Programs**.
@@ -120,3 +147,4 @@ If installed manually via NSSM:
.\nssm.exe stop LOGAR_Server .\nssm.exe stop LOGAR_Server
.\nssm.exe remove LOGAR_Server confirm .\nssm.exe remove LOGAR_Server confirm
``` ```
+635
View File
@@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""
LOGAR Master License Authority (logar.eibl.tech)
Authoritative PKI service and CLI utility responsible for:
1. Maintaining the Master Root CA.
2. Processing intermediate CA CSRs (or .lic request files) and issuing signed
subordinate CA certificates embedding exact seat quotas via custom enterprise X.509 OIDs.
3. Providing an automated mTLS renewal endpoint (/api/license/renew) for active servers.
4. Serving an administrative WebUI and REST API to manage and revoke server licenses.
"""
import os
import sys
import json
import sqlite3
import argparse
import datetime
from typing import Tuple, Optional, Dict, Any
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
# Ensure src/ is importable
SRC_DIR = os.path.dirname(os.path.abspath(__file__))
if SRC_DIR not in sys.path:
sys.path.insert(0, SRC_DIR)
import server_enrollment as enrollment
try:
from fastapi import FastAPI, HTTPException, Request, Header
from fastapi.responses import HTMLResponse, JSONResponse
import uvicorn
FASTAPI_AVAILABLE = True
except ImportError:
FASTAPI_AVAILABLE = False
DEFAULT_DB = "license_authority.db"
DEFAULT_MASTER_CERT_DIR = "master_ca_certs"
def init_authority_db(db_path: str = DEFAULT_DB):
"""Initializes the SQLite schema for tracking issued server licenses and revocations."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS licenses (
license_id TEXT PRIMARY KEY,
server_fingerprint TEXT,
hostname TEXT,
seats INTEGER NOT NULL,
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
renewal_count INTEGER NOT NULL DEFAULT 0,
cert_pem TEXT NOT NULL,
csr_pem TEXT,
client_cert_fingerprint TEXT
)
""")
conn.commit()
conn.close()
def generate_master_root_ca(
cert_dir: str = DEFAULT_MASTER_CERT_DIR,
common_name: str = "LOGAR-Master-Root-CA",
organization: str = "LOGAR Global Licensing",
country: str = "AT"
) -> Tuple[str, str]:
"""Generates the Master Root CA certificate and private key if not already existing."""
os.makedirs(cert_dir, exist_ok=True)
cert_path = os.path.join(cert_dir, "master_root_ca.crt")
key_path = os.path.join(cert_dir, "master_root_ca.key")
if os.path.exists(cert_path) and os.path.exists(key_path):
with open(cert_path, "r", encoding="utf-8") as f:
cert_pem = f.read()
with open(key_path, "r", encoding="utf-8") as f:
key_pem = f.read()
return cert_pem, key_pem
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=False,
key_cert_sign=True,
crl_sign=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False
),
critical=True
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()),
critical=False
)
.sign(ca_key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
key_pem = ca_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode("utf-8")
with open(cert_path, "w", encoding="utf-8") as f:
f.write(cert_pem)
with open(key_path, "w", encoding="utf-8") as f:
f.write(key_pem)
return cert_pem, key_pem
def issue_license_from_csr(
csr_pem: str,
seats: int,
days_valid: int = 365,
license_id: Optional[str] = None,
server_fingerprint: str = "",
hostname: str = "",
db_path: str = DEFAULT_DB,
cert_dir: str = DEFAULT_MASTER_CERT_DIR,
license_server_url: str = "https://logar.eibl.tech"
) -> Dict[str, Any]:
"""Issues a subordinate CA certificate with the requested number of seats and registers it in the DB."""
init_authority_db(db_path)
master_cert_pem, master_key_pem = generate_master_root_ca(cert_dir)
if not license_id:
csr_obj = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
der_pub = csr_obj.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo
)
license_id = f"LIC-{der_pub.hex()[:12].upper()}"
ca_cert_pem = enrollment.sign_ca_csr_with_seats(
csr_pem=csr_pem,
master_ca_cert_pem=master_cert_pem,
master_ca_key_pem=master_key_pem,
seats=seats,
days_valid=days_valid,
license_id=license_id,
license_server_url=license_server_url
)
info = enrollment.extract_license_info_from_cert(ca_cert_pem)
issued_at = info["not_valid_before"]
expires_at = info["not_valid_after"]
fp = info["fingerprint"]
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("""
INSERT INTO licenses (license_id, server_fingerprint, hostname, seats, issued_at, expires_at, status, renewal_count, cert_pem, csr_pem, client_cert_fingerprint)
VALUES (?, ?, ?, ?, ?, ?, 'active', 0, ?, ?, ?)
ON CONFLICT(license_id) DO UPDATE SET
seats = excluded.seats,
expires_at = excluded.expires_at,
status = 'active',
cert_pem = excluded.cert_pem,
client_cert_fingerprint = excluded.client_cert_fingerprint
""", (license_id, server_fingerprint, hostname, seats, issued_at, expires_at, ca_cert_pem, csr_pem, fp))
conn.commit()
conn.close()
return {
"status": "success",
"license_id": license_id,
"seats": seats,
"issued_at": issued_at,
"expires_at": expires_at,
"fingerprint": fp,
"ca_cert": ca_cert_pem,
"master_root_ca": master_cert_pem,
"license_server": license_server_url
}
def renew_license_cert(
current_cert_pem: str,
days_valid: int = 365,
db_path: str = DEFAULT_DB,
cert_dir: str = DEFAULT_MASTER_CERT_DIR,
license_server_url: str = "https://logar.eibl.tech"
) -> Dict[str, Any]:
"""
Validates an existing intermediate CA cert presenting for mTLS renewal,
verifies that the license is active, and issues a renewed certificate preserving seat quota.
"""
init_authority_db(db_path)
master_cert_pem, master_key_pem = generate_master_root_ca(cert_dir)
master_cert = x509.load_pem_x509_certificate(master_cert_pem.encode("utf-8"))
current_cert = x509.load_pem_x509_certificate(current_cert_pem.encode("utf-8"))
# Extract license info
info = enrollment.extract_license_info_from_cert(current_cert_pem)
license_id = info.get("license_id")
seats = info.get("seats") or 10
# Verify license record in database
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT status, renewal_count, csr_pem, seats FROM licenses WHERE license_id = ?", (license_id,))
row = c.fetchone()
if row:
status, renewals, csr_pem, db_seats = row
if status == "revoked":
conn.close()
raise ValueError(f"License {license_id} is REVOKED and cannot be renewed.")
seats = db_seats or seats
else:
# Auto-enroll license if missing from DB (signed by our master CA)
renewals = 0
csr_pem = None
# Build renewed certificate using the same public key and subject
master_key = serialization.load_pem_private_key(master_key_pem.encode("utf-8"), password=None)
now = datetime.datetime.now(datetime.timezone.utc)
builder = (
x509.CertificateBuilder()
.subject_name(current_cert.subject)
.issuer_name(master_cert.subject)
.public_key(current_cert.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=days_valid))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=False,
key_cert_sign=True,
crl_sign=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False
),
critical=True
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(current_cert.public_key()),
critical=False
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(master_key.public_key()),
critical=False
)
.add_extension(
x509.UnrecognizedExtension(enrollment.OID_LOGAR_SEATS, str(seats).encode("utf-8")),
critical=False
)
.add_extension(
x509.UnrecognizedExtension(enrollment.OID_LOGAR_LICENSE_ID, license_id.encode("utf-8")),
critical=False
)
.add_extension(
x509.UnrecognizedExtension(enrollment.OID_LOGAR_ISSUER_URL, license_server_url.encode("utf-8")),
critical=False
)
)
new_cert = builder.sign(master_key, hashes.SHA256())
new_pem = new_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
new_info = enrollment.extract_license_info_from_cert(new_pem)
new_fp = new_info["fingerprint"]
c.execute("""
UPDATE licenses
SET renewal_count = renewal_count + 1,
expires_at = ?,
cert_pem = ?,
client_cert_fingerprint = ?
WHERE license_id = ?
""", (new_info["not_valid_after"], new_pem, new_fp, license_id))
conn.commit()
conn.close()
return {
"status": "renewed",
"license_id": license_id,
"seats": seats,
"ca_cert": new_pem,
"expires_at": new_info["not_valid_after"],
"fingerprint": new_fp
}
def revoke_license(license_id: str, db_path: str = DEFAULT_DB) -> bool:
"""Marks a license as revoked in the database."""
init_authority_db(db_path)
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("UPDATE licenses SET status = 'revoked' WHERE license_id = ?", (license_id,))
changed = c.rowcount > 0
conn.commit()
conn.close()
return changed
def list_licenses(db_path: str = DEFAULT_DB) -> list:
"""Lists all registered licenses."""
init_authority_db(db_path)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT license_id, server_fingerprint, hostname, seats, issued_at, expires_at, status, renewal_count, client_cert_fingerprint FROM licenses ORDER BY issued_at DESC")
rows = [dict(r) for r in c.fetchall()]
conn.close()
return rows
# --- FastAPI Web Service (for deployment at logar.eibl.tech) ---
if FASTAPI_AVAILABLE:
app = FastAPI(title="LOGAR License Authority", version="2.0.1")
@app.post("/api/license/issue")
async def api_issue(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
csr_pem = body.get("csr")
seats = int(body.get("seats", 10))
server_fp = body.get("server_fingerprint", "")
hostname = body.get("hostname", "")
lic_id = body.get("license_id")
if not csr_pem:
raise HTTPException(status_code=400, detail="Missing 'csr' in request")
try:
result = issue_license_from_csr(
csr_pem=csr_pem,
seats=seats,
license_id=lic_id,
server_fingerprint=server_fp,
hostname=hostname
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/license/renew")
async def api_renew(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
current_cert = body.get("current_cert")
if not current_cert:
raise HTTPException(status_code=400, detail="Missing 'current_cert' in request")
try:
result = renew_license_cert(current_cert)
return result
except ValueError as ve:
raise HTTPException(status_code=403, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/licenses")
def api_list():
return {"licenses": list_licenses()}
@app.post("/api/license/revoke")
async def api_revoke(request: Request):
body = await request.json()
lic_id = body.get("license_id")
if not lic_id:
raise HTTPException(status_code=400, detail="Missing 'license_id'")
ok = revoke_license(lic_id)
if not ok:
raise HTTPException(status_code=404, detail="License not found")
return {"status": "revoked", "license_id": lic_id}
@app.get("/", response_class=HTMLResponse)
def web_dashboard():
licenses = list_licenses()
rows = []
for l in licenses:
badge_cls = "badge-active" if l["status"] == "active" else "badge-revoked"
rows.append(f"""
<tr>
<td><code>{l['license_id']}</code></td>
<td><strong>{l['seats']}</strong></td>
<td><span class="badge {badge_cls}">{l['status'].upper()}</span></td>
<td>{l['hostname'] or 'N/A'}</td>
<td><code>{l['client_cert_fingerprint'][:16]}...</code></td>
<td>{l['expires_at'][:10]}</td>
<td>{l['renewal_count']}</td>
<td>
<button class="btn btn-danger" onclick="revokeLic('{l['license_id']}')">Revoke</button>
</td>
</tr>
""")
rows_html = "".join(rows) if rows else "<tr><td colspan='8' style='text-align: center; color: #64748b;'>No licenses issued yet.</td></tr>"
html_tpl = """<!DOCTYPE html>
<html>
<head>
<title>LOGAR Master License Authority (logar.eibl.tech)</title>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; margin: 0; padding: 2rem; }
.container { max-width: 1100px; margin: 0 auto; }
h1 { color: #38bdf8; display: flex; align-items: center; gap: 10px; }
.card { background: #1e293b; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; border: 1px solid #334155; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid #334155; font-size: 0.9rem; }
th { color: #94a3b8; font-weight: 600; text-transform: uppercase; font-size: 0.75rem; }
.badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-weight: bold; font-size: 0.75rem; }
.badge-active { background: #065f46; color: #34d399; }
.badge-revoked { background: #7f1d1d; color: #f87171; }
code { background: #0f172a; padding: 2px 6px; border-radius: 4px; font-family: monospace; color: #a5b4fc; }
.btn { padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
.btn-primary { background: #0284c7; color: white; }
.btn-danger { background: #dc2626; color: white; }
textarea, input { background: #0f172a; border: 1px solid #475569; color: white; padding: 0.5rem; border-radius: 4px; width: 100%; box-sizing: border-box; }
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.5rem; color: #cbd5e1; font-size: 0.85rem; }
</style>
</head>
<body>
<div class="container">
<h1><span>🛡️</span> LOGAR Master License Authority</h1>
<p style="color: #94a3b8;">Authoritative PKI for issuing seat-bearing Intermediate CA certificates and automatic mTLS renewals.</p>
<div class="card">
<h3>Issue Subordinate CA Certificate from CSR / .lic</h3>
<div class="form-group">
<label>Seats Quota (Exact Seat Limit)</label>
<input type="number" id="seats" value="25" min="1" max="10000" style="max-width: 200px;">
</div>
<div class="form-group">
<label>CSR (PEM) or .lic JSON content</label>
<textarea id="csrText" rows="6" placeholder="-----BEGIN CERTIFICATE REQUEST----- ... or JSON .lic"></textarea>
</div>
<button class="btn btn-primary" onclick="issueLicense()">Sign & Issue CA Certificate</button>
<div id="issueOutput" style="margin-top: 1rem; display: none;"></div>
</div>
<div class="card">
<h3>Active & Issued Server Licenses</h3>
<table>
<thead>
<tr>
<th>License ID</th>
<th>Seats</th>
<th>Status</th>
<th>Host</th>
<th>Cert Fingerprint</th>
<th>Expires</th>
<th>Renewals</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
__ROWS_HTML__
</tbody>
</table>
</div>
</div>
<script>
async function issueLicense() {
let raw = document.getElementById('csrText').value.trim();
const seats = parseInt(document.getElementById('seats').value, 10);
let csr = raw;
let fp = "";
let host = "";
if (raw.startsWith('{')) {
try {
const parsed = JSON.parse(raw);
csr = parsed.csr;
fp = parsed.server_fingerprint || "";
host = parsed.hostname || "";
} catch(e) {
alert("Invalid JSON .lic file format");
return;
}
}
const resp = await fetch('/api/license/issue', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ csr: csr, seats: seats, server_fingerprint: fp, hostname: host })
});
const data = await resp.json();
const out = document.getElementById('issueOutput');
out.style.display = 'block';
if (resp.ok) {
out.innerHTML = `
<div style="background: #064e3b; padding: 1rem; border-radius: 4px; border: 1px solid #059669;">
<h4 style="margin: 0 0 0.5rem 0; color: #34d399;">License Issued Successfully! (Seats: ${data.seats})</h4>
<p style="margin: 0; font-size: 0.85rem;">License ID: <code>${data.license_id}</code></p>
<p style="margin: 0.5rem 0 0 0; font-size: 0.85rem;">Copy the signed CA certificate below or download it as <code>ca.crt</code>:</p>
<textarea rows="6" readonly style="margin-top: 0.5rem;">${data.ca_cert}</textarea>
</div>
`;
} else {
out.innerHTML = `<div style="background: #7f1d1d; padding: 1rem; border-radius: 4px;">Error: ${data.detail}</div>`;
}
}
async function revokeLic(licId) {
if (!confirm(`Are you sure you want to revoke license ${licId}?`)) return;
const resp = await fetch('/api/license/revoke', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ license_id: licId })
});
if (resp.ok) {
location.reload();
} else {
alert("Failed to revoke license");
}
}
</script>
</body>
</html>"""
return html_tpl.replace("__ROWS_HTML__", rows_html)
def main():
parser = argparse.ArgumentParser(description="LOGAR Master License Authority (logar.eibl.tech)")
parser.add_argument("--init-master-ca", action="store_true", help="Initialize the Master Root CA")
parser.add_argument("--sign-lic", type=str, help="Path to .lic file or CSR PEM file to sign")
parser.add_argument("--seats", type=int, default=25, help="Number of seats to grant (default: 25)")
parser.add_argument("--out", type=str, default="ca.crt", help="Output path for signed CA cert")
parser.add_argument("--revoke", type=str, help="License ID to revoke")
parser.add_argument("--list", action="store_true", help="List all issued licenses")
parser.add_argument("--serve", action="store_true", help="Run the FastAPI / mTLS renewal service")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Service host (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8080, help="Service port (default: 8080)")
args = parser.parse_args()
if args.init_master_ca:
generate_master_root_ca()
print(f"[+] Master Root CA initialized in {DEFAULT_MASTER_CERT_DIR}/")
return
if args.list:
lics = list_licenses()
print(f"\n{'License ID':<20} {'Seats':<8} {'Status':<10} {'Host':<20} {'Expires':<12} {'Renewals':<8}")
print("-" * 80)
for l in lics:
print(f"{l['license_id']:<20} {l['seats']:<8} {l['status']:<10} {l['hostname'] or 'N/A':<20} {l['expires_at'][:10]:<12} {l['renewal_count']:<8}")
return
if args.revoke:
if revoke_license(args.revoke):
print(f"[+] License {args.revoke} revoked successfully.")
else:
print(f"[-] License {args.revoke} not found.")
return
if args.sign_lic:
with open(args.sign_lic, "r", encoding="utf-8") as f:
content = f.read()
csr_pem = content
server_fp = ""
hostname = ""
if content.strip().startswith("{"):
data = json.loads(content)
csr_pem = data["csr"]
server_fp = data.get("server_fingerprint", "")
hostname = data.get("hostname", "")
res = issue_license_from_csr(
csr_pem=csr_pem,
seats=args.seats,
server_fingerprint=server_fp,
hostname=hostname
)
with open(args.out, "w", encoding="utf-8") as f:
f.write(res["ca_cert"])
print(f"[+] Signed CA certificate issued successfully!")
print(f" - License ID: {res['license_id']}")
print(f" - Granted Seats: {res['seats']}")
print(f" - Expires: {res['expires_at']}")
print(f" - Saved to: {args.out}")
return
if args.serve:
if not FASTAPI_AVAILABLE:
print("[-] FastAPI/uvicorn not installed.")
sys.exit(1)
generate_master_root_ca()
print(f"[*] Starting LOGAR License Authority at http://{args.host}:{args.port}")
uvicorn.run(app, host=args.host, port=args.port)
return
parser.print_help()
if __name__ == "__main__":
main()
+1743 -781
View File
File diff suppressed because it is too large Load Diff
+359 -1
View File
@@ -1,12 +1,24 @@
import os import os
import json
import ssl
import datetime import datetime
import ipaddress import ipaddress
from typing import Tuple, List, Optional import urllib.request
import urllib.error
import urllib.parse
import http.client
from typing import Tuple, List, Optional, Dict, Any
from cryptography import x509 from cryptography import x509
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
# Custom Enterprise X.509 OIDs for LOGAR Licensing
OID_LOGAR_SEATS = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.1")
OID_LOGAR_LICENSE_ID = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.2")
OID_LOGAR_ISSUER_URL = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.3")
def calculate_cert_fingerprint(cert_pem: str) -> str: def calculate_cert_fingerprint(cert_pem: str) -> str:
"""Computes SHA-256 fingerprint for a PEM-encoded X.509 certificate.""" """Computes SHA-256 fingerprint for a PEM-encoded X.509 certificate."""
@@ -378,3 +390,349 @@ def check_and_renew_hub_pki(
return False, False return False, False
def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
"""
Extracts embedded licensing attributes, validity, and metadata from a PEM X.509 certificate.
Supports decoding seat quota and license IDs embedded via LOGAR custom enterprise OIDs.
"""
try:
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8"))
except Exception as e:
return {"valid": False, "error": f"Invalid certificate format: {e}"}
seats = None
license_id = None
issuer_url = None
try:
ext_seats = cert.extensions.get_extension_for_oid(OID_LOGAR_SEATS)
if ext_seats and ext_seats.value:
raw_val = ext_seats.value.value if hasattr(ext_seats.value, "value") else str(ext_seats.value)
if isinstance(raw_val, bytes):
seats = int(raw_val.decode("utf-8").strip())
else:
seats = int(str(raw_val).strip())
except (x509.ExtensionNotFound, ValueError, Exception):
seats = None
try:
ext_lic = cert.extensions.get_extension_for_oid(OID_LOGAR_LICENSE_ID)
if ext_lic and ext_lic.value:
raw_val = ext_lic.value.value if hasattr(ext_lic.value, "value") else str(ext_lic.value)
license_id = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip()
except (x509.ExtensionNotFound, Exception):
license_id = None
try:
ext_url = cert.extensions.get_extension_for_oid(OID_LOGAR_ISSUER_URL)
if ext_url and ext_url.value:
raw_val = ext_url.value.value if hasattr(ext_url.value, "value") else str(ext_url.value)
issuer_url = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip()
except (x509.ExtensionNotFound, Exception):
issuer_url = None
not_before = getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before.replace(tzinfo=datetime.timezone.utc)
not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after.replace(tzinfo=datetime.timezone.utc)
now = datetime.datetime.now(datetime.timezone.utc)
is_expired = now > not_after
days_remaining = max(0, (not_after - now).days) if not is_expired else 0
is_ca = False
try:
bc_ext = cert.extensions.get_extension_for_oid(x509.OID_BASIC_CONSTRAINTS)
if bc_ext and bc_ext.value:
is_ca = bc_ext.value.ca
except Exception:
pass
subject_cn = ""
for attr in cert.subject:
if attr.oid == NameOID.COMMON_NAME:
subject_cn = attr.value
break
issuer_cn = ""
for attr in cert.issuer:
if attr.oid == NameOID.COMMON_NAME:
issuer_cn = attr.value
break
is_self_signed = (cert.subject == cert.issuer)
return {
"valid": True,
"seats": seats,
"license_id": license_id,
"issuer_url": issuer_url,
"subject_cn": subject_cn,
"issuer_cn": issuer_cn,
"is_ca": is_ca,
"is_self_signed": is_self_signed,
"fingerprint": cert.fingerprint(hashes.SHA256()).hex().upper(),
"not_valid_before": not_before.isoformat(),
"not_valid_after": not_after.isoformat(),
"is_expired": is_expired,
"days_remaining": days_remaining
}
def generate_ca_key_if_needed(cert_dir: str = "certs") -> Tuple[rsa.RSAPrivateKey, str]:
"""Ensures ca.key exists in cert_dir, generating a 4096-bit RSA key if not."""
os.makedirs(cert_dir, exist_ok=True)
ca_key_path = os.path.join(cert_dir, "ca.key")
if os.path.exists(ca_key_path):
with open(ca_key_path, "r", encoding="utf-8") as f:
key_pem = f.read()
try:
ca_key = serialization.load_pem_private_key(key_pem.encode("utf-8"), password=None)
return ca_key, key_pem
except Exception:
pass
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
key_pem = ca_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode("utf-8")
with open(ca_key_path, "w", encoding="utf-8") as f:
f.write(key_pem)
return ca_key, key_pem
def generate_ca_csr(
cert_dir: str = "certs",
common_name: str = "LOGAR-Sub-CA",
organization: str = "LOGAR",
country: str = "AT"
) -> Tuple[str, str]:
"""
Generates a Certificate Signing Request (CSR) for a subordinate CA using ca.key.
Returns (csr_pem, ca_key_pem).
"""
ca_key, key_pem = generate_ca_key_if_needed(cert_dir)
subject = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(subject)
.add_extension(
x509.BasicConstraints(ca=True, path_length=0),
critical=True
)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=False,
key_cert_sign=True,
crl_sign=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False
),
critical=True
)
.sign(ca_key, hashes.SHA256())
)
csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8")
csr_path = os.path.join(cert_dir, "ca.csr")
with open(csr_path, "w", encoding="utf-8") as f:
f.write(csr_pem)
return csr_pem, key_pem
def create_license_request_lic(
cert_dir: str = "certs",
license_server: str = "https://logar.eibl.tech",
server_fingerprint: str = "",
hostname: str = "",
requested_seats: int = 10,
common_name: str = "LOGAR-Sub-CA"
) -> Tuple[Dict[str, Any], str]:
"""
Packages a CA CSR and server metadata into an exportable .lic file dictionary and JSON string.
"""
csr_pem, _ = generate_ca_csr(cert_dir=cert_dir, common_name=common_name)
lic_data = {
"format": "LOGAR_LICENSE_REQUEST",
"version": "1.0",
"license_server": license_server,
"server_fingerprint": server_fingerprint,
"hostname": hostname,
"requested_seats": requested_seats,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"csr": csr_pem
}
lic_json = json.dumps(lic_data, indent=2)
lic_path = os.path.join(cert_dir, "logar_server.lic")
with open(lic_path, "w", encoding="utf-8") as f:
f.write(lic_json)
return lic_data, lic_json
def sign_ca_csr_with_seats(
csr_pem: str,
master_ca_cert_pem: str,
master_ca_key_pem: str,
seats: int,
days_valid: int = 365,
license_id: Optional[str] = None,
license_server_url: str = "https://logar.eibl.tech"
) -> str:
"""
Signs a subordinate CA CSR using the master license authority Root CA.
Embeds the approved seat count and license ID via custom X.509 extensions.
Returns the signed PEM certificate.
"""
csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
if not csr.is_signature_valid:
raise ValueError("Invalid CSR signature")
master_ca_cert = x509.load_pem_x509_certificate(master_ca_cert_pem.encode("utf-8"))
master_ca_key = serialization.load_pem_private_key(master_ca_key_pem.encode("utf-8"), password=None)
now = datetime.datetime.now(datetime.timezone.utc)
if not license_id:
license_id = f"LIC-{csr.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo).hex()[:12].upper()}"
builder = (
x509.CertificateBuilder()
.subject_name(csr.subject)
.issuer_name(master_ca_cert.subject)
.public_key(csr.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=days_valid))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=False,
key_cert_sign=True,
crl_sign=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False
),
critical=True
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(csr.public_key()),
critical=False
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(master_ca_key.public_key()),
critical=False
)
.add_extension(
x509.UnrecognizedExtension(OID_LOGAR_SEATS, str(seats).encode("utf-8")),
critical=False
)
.add_extension(
x509.UnrecognizedExtension(OID_LOGAR_LICENSE_ID, license_id.encode("utf-8")),
critical=False
)
.add_extension(
x509.UnrecognizedExtension(OID_LOGAR_ISSUER_URL, license_server_url.encode("utf-8")),
critical=False
)
)
signed_cert = builder.sign(master_ca_key, hashes.SHA256())
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
def renew_ca_cert_mtls(
cert_dir: str = "certs",
license_server_url: str = "https://logar.eibl.tech",
upstream_ca_pem: Optional[str] = None,
timeout: int = 15,
verify_tls: bool = True
) -> Tuple[bool, str, Optional[str]]:
"""
Connects to the master License Authority using mutual TLS (mTLS), authenticating
with the server's current ca.crt and ca.key, and requests an automated renewal.
If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir.
Returns (success, message, new_ca_cert_pem).
"""
ca_cert_path = os.path.join(cert_dir, "ca.crt")
ca_key_path = os.path.join(cert_dir, "ca.key")
if not os.path.exists(ca_cert_path) or not os.path.exists(ca_key_path):
return False, f"Missing ca.crt or ca.key in {cert_dir}", None
renew_url = f"{license_server_url.rstrip('/')}/api/license/renew"
parsed = urllib.parse.urlparse(renew_url)
try:
with open(ca_cert_path, "r", encoding="utf-8") as f:
current_pem = f.read()
info = extract_license_info_from_cert(current_pem)
payload = json.dumps({
"license_id": info.get("license_id"),
"fingerprint": info.get("fingerprint"),
"current_cert": current_pem
}).encode("utf-8")
path = parsed.path or "/api/license/renew"
headers = {
"Content-Type": "application/json",
"User-Agent": "LOGAR-Server-mTLS/2.0"
}
if parsed.scheme == "https":
ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
if not verify_tls:
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
elif upstream_ca_pem:
ssl_ctx.load_verify_locations(cdata=upstream_ca_pem)
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
if os.path.exists(upstream_ca_path) and verify_tls and not upstream_ca_pem:
ssl_ctx.load_verify_locations(cafile=upstream_ca_path)
ssl_ctx.load_cert_chain(certfile=ca_cert_path, keyfile=ca_key_path)
conn = http.client.HTTPSConnection(parsed.netloc, context=ssl_ctx, timeout=timeout)
else:
conn = http.client.HTTPConnection(parsed.netloc, timeout=timeout)
conn.request("POST", path, body=payload, headers=headers)
resp = conn.getresponse()
resp_data = resp.read().decode("utf-8")
conn.close()
if resp.status != 200:
return False, f"License server returned HTTP {resp.status}: {resp_data}", None
result = json.loads(resp_data)
new_cert_pem = result.get("ca_cert")
if not new_cert_pem:
return False, f"License server response missing 'ca_cert': {resp_data}", None
# Backup old ca.crt
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak")
# Write new ca.crt
with open(ca_cert_path, "w", encoding="utf-8") as f:
f.write(new_cert_pem)
return True, "Certificate renewed successfully via mTLS", new_cert_pem
except Exception as e:
return False, f"mTLS renewal exception: {str(e)}", None
+229
View File
@@ -0,0 +1,229 @@
import os
import sys
import json
import shutil
import sqlite3
import unittest
from datetime import datetime, timezone, timedelta
# Ensure src/ is importable
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SRC_DIR = os.path.join(ROOT_DIR, "src")
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
if SRC_DIR not in sys.path:
sys.path.insert(0, SRC_DIR)
import server_enrollment as enrollment
import License_Authority as authority
import Server
class TestLicenseAuthorityAndWebUI(unittest.TestCase):
def setUp(self):
self.test_dir = os.path.join(ROOT_DIR, "test_lic_tmp")
os.makedirs(self.test_dir, exist_ok=True)
self.master_ca_dir = os.path.join(self.test_dir, "master_ca")
self.server_cert_dir = os.path.join(self.test_dir, "server_certs")
self.db_path = os.path.join(self.test_dir, "license_auth.db")
self.server_db = os.path.join(self.test_dir, "server_state.db")
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_master_root_ca_generation(self):
"""Verifies Master Root CA creation and structure."""
cert_pem, key_pem = authority.generate_master_root_ca(cert_dir=self.master_ca_dir)
self.assertIn("BEGIN CERTIFICATE", cert_pem)
self.assertIn("BEGIN RSA PRIVATE KEY", key_pem)
self.assertTrue(os.path.exists(os.path.join(self.master_ca_dir, "master_root_ca.crt")))
def test_ca_csr_and_lic_request_generation(self):
"""Verifies subordinate CA CSR and .lic request file generation."""
lic_data, lic_json = enrollment.create_license_request_lic(
cert_dir=self.server_cert_dir,
license_server="https://logar.eibl.tech",
server_fingerprint="AABBCCDDEEFF00112233",
hostname="test-hub.corp.internal",
requested_seats=30
)
self.assertEqual(lic_data["format"], "LOGAR_LICENSE_REQUEST")
self.assertEqual(lic_data["license_server"], "https://logar.eibl.tech")
self.assertEqual(lic_data["requested_seats"], 30)
self.assertIn("BEGIN CERTIFICATE REQUEST", lic_data["csr"])
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "ca.csr")))
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "logar_server.lic")))
def test_authority_issue_sub_ca_with_exact_seats(self):
"""Verifies that License Authority signs CSR, embeds exact seat quota via custom OID, and verifies in DB."""
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
result = authority.issue_license_from_csr(
csr_pem=csr_pem,
seats=42,
days_valid=365,
server_fingerprint="FP123456",
hostname="hub-node-1",
db_path=self.db_path,
cert_dir=self.master_ca_dir
)
self.assertEqual(result["status"], "success")
self.assertEqual(result["seats"], 42)
self.assertIn("BEGIN CERTIFICATE", result["ca_cert"])
# Decode certificate to verify seat quota extraction
info = enrollment.extract_license_info_from_cert(result["ca_cert"])
self.assertTrue(info["valid"])
self.assertEqual(info["seats"], 42)
self.assertEqual(info["license_id"], result["license_id"])
self.assertEqual(info["issuer_url"], "https://logar.eibl.tech")
self.assertFalse(info["is_expired"])
self.assertTrue(info["is_ca"])
# Check authority database
lics = authority.list_licenses(db_path=self.db_path)
self.assertEqual(len(lics), 1)
self.assertEqual(lics[0]["license_id"], result["license_id"])
self.assertEqual(lics[0]["seats"], 42)
self.assertEqual(lics[0]["status"], "active")
def test_authority_renewal_and_revocation(self):
"""Verifies that an active license cert can be renewed, and revoked licenses are rejected."""
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
initial = authority.issue_license_from_csr(
csr_pem=csr_pem,
seats=15,
db_path=self.db_path,
cert_dir=self.master_ca_dir
)
lic_id = initial["license_id"]
# Successful renewal
renewed = authority.renew_license_cert(
current_cert_pem=initial["ca_cert"],
days_valid=365,
db_path=self.db_path,
cert_dir=self.master_ca_dir
)
self.assertEqual(renewed["status"], "renewed")
self.assertEqual(renewed["seats"], 15)
# Check renewal count in DB
lics = authority.list_licenses(db_path=self.db_path)
self.assertEqual(lics[0]["renewal_count"], 1)
# Revoke the license
revoked = authority.revoke_license(lic_id, db_path=self.db_path)
self.assertTrue(revoked)
# Renewal attempt after revocation must fail
with self.assertRaises(ValueError):
authority.renew_license_cert(
current_cert_pem=renewed["ca_cert"],
db_path=self.db_path,
cert_dir=self.master_ca_dir
)
def test_server_license_import_and_seat_update(self):
"""Verifies that importing a signed CA cert updates the server's seat quota in DB and memory."""
# 1. Generate CSR on server side
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
# 2. Authority signs it with 50 seats
signed = authority.issue_license_from_csr(
csr_pem=csr_pem,
seats=50,
db_path=self.db_path,
cert_dir=self.master_ca_dir
)
# 3. Setup mock server state and DB
Server.init_db(self.server_db, max_seats=5)
Server.SERVER_STATE["config"] = {
"db_path": self.server_db,
"cert_dir": self.server_cert_dir,
"max_seats": 5,
"tcp_host": "127.0.0.1",
"tcp_port": 9443,
"hermes_host": "127.0.0.1",
"hermes_port": 8443,
"server_fingerprint": "MOCK_FP",
"server_name": "Test-Hub",
"license_server_url": "https://logar.eibl.tech"
}
# 4. Extract summary before import
summary_before = Server.get_license_summary()
self.assertEqual(summary_before["max_seats"], 5)
# 5. Save the signed CA certificate to cert_dir/ca.crt
ca_path = os.path.join(self.server_cert_dir, "ca.crt")
with open(ca_path, "w", encoding="utf-8") as f:
f.write(signed["ca_cert"])
# 6. Extract summary after import
summary_after = Server.get_license_summary()
self.assertEqual(summary_after["max_seats"], 50)
self.assertEqual(summary_after["license_id"], signed["license_id"])
self.assertEqual(summary_after["status"], "ACTIVE")
def test_server_client_revocation_and_reactivation(self):
"""Verifies revocation and reactivation of edge client seats."""
Server.init_db(self.server_db, max_seats=2)
Server.SERVER_STATE["config"] = {
"db_path": self.server_db,
"cert_dir": self.server_cert_dir,
"max_seats": 2
}
# Insert active clients
conn = sqlite3.connect(self.server_db)
conn.execute("INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status) VALUES ('c1', 'win1', 'windows', 'FP1', 'active')")
conn.execute("INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status) VALUES ('c2', 'lin1', 'linux', 'FP2', 'active')")
conn.commit()
conn.close()
# Revoke c1
req = Server.ClientActionRequest(client_id="c1")
res = Server.revoke_client(req)
self.assertEqual(res["status"], "revoked")
summary = Server.get_license_summary()
self.assertEqual(summary["active_seats"], 1)
self.assertEqual(summary["revoked_seats"], 1)
self.assertEqual(summary["available_seats"], 1)
# Reactivate c1
res_react = Server.reactivate_client(req)
self.assertEqual(res_react["status"], "active")
summary2 = Server.get_license_summary()
self.assertEqual(summary2["active_seats"], 2)
self.assertEqual(summary2["available_seats"], 0)
def test_webui_rendering(self):
"""Verifies that the WebUI HTML contains key components, stats, and download triggers."""
Server.init_db(self.server_db, max_seats=10)
Server.SERVER_STATE["config"] = {
"db_path": self.server_db,
"cert_dir": self.server_cert_dir,
"max_seats": 10,
"tcp_port": 9443,
"hermes_port": 8443,
"server_name": "Test-Cloud-Hub",
"server_fingerprint": "1234567890ABCDEF",
"license_server_url": "https://logar.eibl.tech"
}
response = Server.serve_webui()
html = response.body.decode("utf-8")
self.assertIn("LOGAR Fleet Hub &amp; License Management", html.replace("&", "&amp;"))
self.assertIn("Export .lic Request", html)
self.assertIn("Import &amp; Activate Signed CA Certificate", html.replace("&", "&amp;"))
self.assertIn("Renew via mTLS Now", html)
self.assertIn("logar.eibl.tech", html)
if __name__ == "__main__":
unittest.main()