chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
InSAR license issuer for the LIC2 format.
|
||||
|
||||
This module keeps the existing CLI workflow, and also exposes reusable
|
||||
functions for the desktop GUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
ISSUER_DIR = Path(__file__).resolve().parent
|
||||
PRIVATE_KEY_FILE = ISSUER_DIR / "private_key.b64"
|
||||
PUBLIC_KEY_FILE = ISSUER_DIR / "public_key.b64"
|
||||
BACKEND_LICENSE_SERVICE_FILE = ISSUER_DIR.parent / "backend" / "app" / "license_service.py"
|
||||
LICENSE_HEADER = b"LIC2"
|
||||
PUBLIC_KEY_PATTERN = re.compile(r'^_PUBLIC_KEY_B64\s*=\s*"([^"]*)"', re.MULTILINE)
|
||||
|
||||
|
||||
def _load_cryptography():
|
||||
try:
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
|
||||
return Ed25519PrivateKey, Ed25519PublicKey, InvalidSignature, serialization
|
||||
except ImportError:
|
||||
raise RuntimeError("Missing dependency: pip install cryptography")
|
||||
|
||||
|
||||
def resolve_backend_license_service_file(target: str | Path | None = None) -> Path:
|
||||
if target is None:
|
||||
env_target = str(os.environ.get("LICENSE_ISSUER_BACKEND_FILE", "")).strip()
|
||||
if env_target:
|
||||
return Path(env_target).expanduser()
|
||||
return BACKEND_LICENSE_SERVICE_FILE
|
||||
return Path(target).expanduser()
|
||||
|
||||
|
||||
def _run(args: list[str]) -> str:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
args,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
shell=False,
|
||||
)
|
||||
return output.decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _run_ps(command: str) -> str:
|
||||
return _run(["powershell", "-NoProfile", "-Command", command])
|
||||
|
||||
|
||||
def _pick_value(text: str) -> str:
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
if len(lines) <= 1:
|
||||
return ""
|
||||
values = [value for value in lines[1:] if value.lower() not in {"serialnumber", "uuid"}]
|
||||
return values[0] if values else ""
|
||||
|
||||
|
||||
def get_machine_fingerprint() -> str:
|
||||
uuid_text = _run(["wmic", "csproduct", "get", "uuid"])
|
||||
if not uuid_text:
|
||||
uuid_text = _run_ps("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
|
||||
|
||||
disk_text = _run(["wmic", "diskdrive", "get", "serialnumber"])
|
||||
if not disk_text:
|
||||
disk_text = _run_ps(
|
||||
"(Get-CimInstance Win32_DiskDrive | Select-Object -First 1 -ExpandProperty SerialNumber)"
|
||||
)
|
||||
|
||||
uuid_value = _pick_value(uuid_text)
|
||||
disk_value = _pick_value(disk_text)
|
||||
mac_value = f"{uuid.getnode():012x}"
|
||||
if not mac_value or mac_value == "000000000000":
|
||||
mac_value = _run_ps(
|
||||
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | "
|
||||
"Select-Object -First 1 -ExpandProperty MacAddress)"
|
||||
) or ""
|
||||
|
||||
raw = "|".join([uuid_value, disk_value, mac_value])
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def read_public_key_b64(path: Path = PUBLIC_KEY_FILE) -> str:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Public key file not found: {path}")
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def read_private_key_b64(path: Path = PRIVATE_KEY_FILE) -> str:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Private key file not found: {path}")
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def read_backend_public_key_b64(path: str | Path | None = None) -> Optional[str]:
|
||||
target_path = resolve_backend_license_service_file(path)
|
||||
if not target_path.exists():
|
||||
return None
|
||||
content = target_path.read_text(encoding="utf-8")
|
||||
match = PUBLIC_KEY_PATTERN.search(content)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def get_key_status(backend_target: str | Path | None = None) -> Dict[str, Any]:
|
||||
backend_path = resolve_backend_license_service_file(backend_target)
|
||||
public_key_b64 = None
|
||||
backend_public_key_b64 = None
|
||||
if PUBLIC_KEY_FILE.exists():
|
||||
public_key_b64 = read_public_key_b64(PUBLIC_KEY_FILE)
|
||||
if backend_path.exists():
|
||||
backend_public_key_b64 = read_backend_public_key_b64(backend_path)
|
||||
return {
|
||||
"private_key_exists": PRIVATE_KEY_FILE.exists(),
|
||||
"public_key_exists": PUBLIC_KEY_FILE.exists(),
|
||||
"private_key_path": str(PRIVATE_KEY_FILE),
|
||||
"public_key_path": str(PUBLIC_KEY_FILE),
|
||||
"backend_license_service_path": str(backend_path),
|
||||
"backend_license_service_exists": backend_path.exists(),
|
||||
"public_key_b64": public_key_b64,
|
||||
"backend_public_key_b64": backend_public_key_b64,
|
||||
"backend_synced": bool(public_key_b64 and public_key_b64 == backend_public_key_b64),
|
||||
}
|
||||
|
||||
|
||||
def _load_private_key():
|
||||
Ed25519PrivateKey, _, _, _ = _load_cryptography()
|
||||
raw = base64.b64decode(read_private_key_b64(PRIVATE_KEY_FILE))
|
||||
return Ed25519PrivateKey.from_private_bytes(raw)
|
||||
|
||||
|
||||
def rotate_key_pair(force: bool = False) -> Dict[str, Any]:
|
||||
Ed25519PrivateKey, _, _, serialization = _load_cryptography()
|
||||
|
||||
if PRIVATE_KEY_FILE.exists() and not force:
|
||||
raise FileExistsError(
|
||||
"Private key already exists. Use force=True only if you really want to rotate it."
|
||||
)
|
||||
|
||||
key = Ed25519PrivateKey.generate()
|
||||
private_key_b64 = base64.b64encode(
|
||||
key.private_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PrivateFormat.Raw,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
).decode("utf-8")
|
||||
public_key_b64 = base64.b64encode(
|
||||
key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
).decode("utf-8")
|
||||
|
||||
PRIVATE_KEY_FILE.write_text(private_key_b64, encoding="utf-8")
|
||||
PUBLIC_KEY_FILE.write_text(public_key_b64, encoding="utf-8")
|
||||
|
||||
return {
|
||||
"private_key_path": str(PRIVATE_KEY_FILE),
|
||||
"public_key_path": str(PUBLIC_KEY_FILE),
|
||||
"public_key_b64": public_key_b64,
|
||||
"force": force,
|
||||
}
|
||||
|
||||
|
||||
def sync_backend_public_key(
|
||||
*,
|
||||
public_key_b64: Optional[str] = None,
|
||||
target_path: str | Path | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
resolved_target_path = resolve_backend_license_service_file(target_path)
|
||||
key_b64 = (public_key_b64 or read_public_key_b64(PUBLIC_KEY_FILE)).strip()
|
||||
if not resolved_target_path.exists():
|
||||
raise FileNotFoundError(f"Backend license service file not found: {resolved_target_path}")
|
||||
|
||||
content = resolved_target_path.read_text(encoding="utf-8")
|
||||
match = PUBLIC_KEY_PATTERN.search(content)
|
||||
if not match:
|
||||
raise ValueError("Could not find _PUBLIC_KEY_B64 in backend license service.")
|
||||
|
||||
old_key_b64 = match.group(1)
|
||||
updated_content = PUBLIC_KEY_PATTERN.sub(
|
||||
f'_PUBLIC_KEY_B64 = "{key_b64}"',
|
||||
content,
|
||||
count=1,
|
||||
)
|
||||
resolved_target_path.write_text(updated_content, encoding="utf-8")
|
||||
|
||||
return {
|
||||
"target_path": str(resolved_target_path),
|
||||
"old_public_key_b64": old_key_b64,
|
||||
"public_key_b64": key_b64,
|
||||
"changed": old_key_b64 != key_b64,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_output_path(issued_to: str, output: Optional[str]) -> Path:
|
||||
if output:
|
||||
return Path(output)
|
||||
safe_name = re.sub(r"[^0-9A-Za-z_\-\u4e00-\u9fff]+", "_", issued_to).strip("_")
|
||||
safe_name = safe_name[:32] or "license"
|
||||
return ISSUER_DIR / f"license_{safe_name}.lic"
|
||||
|
||||
|
||||
def issue_license_file(
|
||||
*,
|
||||
issued_to: str,
|
||||
fingerprint: str,
|
||||
days: int = 365,
|
||||
output: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not issued_to.strip():
|
||||
raise ValueError("issued_to is required")
|
||||
if not fingerprint.strip():
|
||||
raise ValueError("fingerprint is required")
|
||||
|
||||
private_key = _load_private_key()
|
||||
issued_at = datetime.now(timezone.utc)
|
||||
expires_at = issued_at + timedelta(days=int(days))
|
||||
|
||||
payload = {
|
||||
"issued_to": issued_to.strip(),
|
||||
"fingerprint": fingerprint.strip(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"issued_at": issued_at.isoformat(),
|
||||
}
|
||||
|
||||
payload_b64 = base64.b64encode(
|
||||
json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
)
|
||||
signature = private_key.sign(payload_b64)
|
||||
signature_b64 = base64.b64encode(signature)
|
||||
blob = LICENSE_HEADER + b"|" + signature_b64 + b"|" + payload_b64
|
||||
|
||||
output_path = _normalize_output_path(payload["issued_to"], output)
|
||||
output_path.write_bytes(blob)
|
||||
|
||||
return {
|
||||
"output_path": str(output_path),
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
def verify_license_file(license_file: str | Path) -> Dict[str, Any]:
|
||||
_, Ed25519PublicKey, InvalidSignature, _ = _load_cryptography()
|
||||
|
||||
license_path = Path(license_file)
|
||||
if not license_path.exists():
|
||||
raise FileNotFoundError(f"License file not found: {license_path}")
|
||||
|
||||
public_key_raw = base64.b64decode(read_public_key_b64(PUBLIC_KEY_FILE))
|
||||
public_key = Ed25519PublicKey.from_public_bytes(public_key_raw)
|
||||
|
||||
try:
|
||||
blob = license_path.read_bytes().strip()
|
||||
header, signature_b64, payload_b64 = blob.split(b"|", 2)
|
||||
if header != LICENSE_HEADER:
|
||||
raise ValueError("Invalid license header")
|
||||
public_key.verify(base64.b64decode(signature_b64), payload_b64)
|
||||
payload = json.loads(base64.b64decode(payload_b64))
|
||||
except (InvalidSignature, ValueError, json.JSONDecodeError) as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"license_file": str(license_path),
|
||||
"reason": str(exc),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"license_file": str(license_path),
|
||||
"reason": f"Failed to parse license: {exc}",
|
||||
}
|
||||
|
||||
expires_at_text = str(payload.get("expires_at") or "")
|
||||
expires_at = datetime.fromisoformat(expires_at_text)
|
||||
expired = datetime.now(timezone.utc) > expires_at
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"license_file": str(license_path),
|
||||
"issued_to": payload.get("issued_to"),
|
||||
"fingerprint": payload.get("fingerprint"),
|
||||
"issued_at": payload.get("issued_at"),
|
||||
"expires_at": expires_at_text,
|
||||
"expired": expired,
|
||||
}
|
||||
|
||||
|
||||
def cmd_rotate_key(args: argparse.Namespace) -> int:
|
||||
result = rotate_key_pair(force=bool(args.force))
|
||||
print("=" * 60)
|
||||
print("New key pair generated")
|
||||
print(f"Private key: {result['private_key_path']}")
|
||||
print(f"Public key : {result['public_key_path']}")
|
||||
print()
|
||||
print("Update backend/app/license_service.py with:")
|
||||
print(f'_PUBLIC_KEY_B64 = "{result["public_key_b64"]}"')
|
||||
print("=" * 60)
|
||||
|
||||
if getattr(args, "sync_backend", False):
|
||||
sync_result = sync_backend_public_key(
|
||||
public_key_b64=result["public_key_b64"],
|
||||
target_path=args.target,
|
||||
)
|
||||
print(f"Backend public key synced: {sync_result['target_path']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sync_public_key(args: argparse.Namespace) -> int:
|
||||
result = sync_backend_public_key(target_path=args.target)
|
||||
status = "updated" if result["changed"] else "already synced"
|
||||
print(f"Backend public key {status}: {result['target_path']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_issue(args: argparse.Namespace) -> int:
|
||||
result = issue_license_file(
|
||||
issued_to=args.to,
|
||||
fingerprint=args.fingerprint,
|
||||
days=int(args.days),
|
||||
output=args.output,
|
||||
)
|
||||
payload = result["payload"]
|
||||
print(f"License file generated: {result['output_path']}")
|
||||
print(f"Issued to : {payload['issued_to']}")
|
||||
print(f"Fingerprint : {payload['fingerprint']}")
|
||||
print(f"Expires at : {payload['expires_at']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args: argparse.Namespace) -> int:
|
||||
result = verify_license_file(args.license_file)
|
||||
if not result["ok"]:
|
||||
print(f"[invalid] {result['reason']}")
|
||||
return 1
|
||||
print("[valid] Signature verified")
|
||||
print(f"Issued to : {result.get('issued_to')}")
|
||||
print(f"Fingerprint : {result.get('fingerprint')}")
|
||||
print(f"Issued at : {result.get('issued_at')}")
|
||||
print(f"Expires at : {result.get('expires_at')}")
|
||||
print(f"Expired : {'yes' if result.get('expired') else 'no'}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_fingerprint(_args: argparse.Namespace) -> int:
|
||||
print(get_machine_fingerprint())
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="InSAR license issuer")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
rotate = sub.add_parser("rotate-key", help="Generate a new key pair")
|
||||
rotate.add_argument("--force", action="store_true", help="Overwrite existing private/public key files")
|
||||
rotate.add_argument(
|
||||
"--sync-backend",
|
||||
action="store_true",
|
||||
help="After rotation, also update backend/app/license_service.py",
|
||||
)
|
||||
rotate.add_argument("--target", default=None, help="Optional backend/app/license_service.py path")
|
||||
|
||||
sync_public = sub.add_parser("sync-public-key", help="Sync public_key.b64 into backend/app/license_service.py")
|
||||
sync_public.add_argument("--target", default=None, help="Optional target file path")
|
||||
|
||||
issue = sub.add_parser("issue", help="Issue a .lic file")
|
||||
issue.add_argument("--to", required=True, help="Organization or customer name")
|
||||
issue.add_argument("--fingerprint", required=True, help="Target machine fingerprint")
|
||||
issue.add_argument("--days", default=365, help="Validity in days")
|
||||
issue.add_argument("--output", default=None, help="Output .lic path")
|
||||
|
||||
verify = sub.add_parser("verify", help="Verify a .lic file against public_key.b64")
|
||||
verify.add_argument("license_file", help="Path to .lic file")
|
||||
|
||||
sub.add_parser("fingerprint", help="Print the current machine fingerprint")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.cmd == "rotate-key":
|
||||
return cmd_rotate_key(args)
|
||||
if args.cmd == "sync-public-key":
|
||||
return cmd_sync_public_key(args)
|
||||
if args.cmd == "issue":
|
||||
return cmd_issue(args)
|
||||
if args.cmd == "verify":
|
||||
return cmd_verify(args)
|
||||
if args.cmd == "fingerprint":
|
||||
return cmd_fingerprint(args)
|
||||
|
||||
parser.print_help()
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[error] {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user