mirror of https://github.com/aliasrobotics/cai.git
317 lines
10 KiB
Python
317 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
CAI License Check (single-machine binding with cryptographic signature verification)
|
|
|
|
Flow:
|
|
- Saves provided API key encrypted locally (OpenSSL AES-256-CBC with local key file).
|
|
- Computes a machine fingerprint.
|
|
- Calls Alias validation endpoint and verifies cryptographic signature.
|
|
- Server signs: system_hash + nonce + "true"/"false"
|
|
- Client verifies signature and only accepts if message ends with "true"
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import platform
|
|
import random
|
|
import shutil
|
|
import string
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
# Try to import from both pycryptodome (Crypto) and python3-pycryptodome (Cryptodome)
|
|
try:
|
|
from Crypto.Hash import SHA256
|
|
from Crypto.PublicKey import RSA
|
|
from Crypto.Signature import pkcs1_15
|
|
except ImportError:
|
|
try:
|
|
from Cryptodome.Hash import SHA256
|
|
from Cryptodome.PublicKey import RSA
|
|
from Cryptodome.Signature import pkcs1_15
|
|
except ImportError:
|
|
print("Error: pycryptodome library not installed. Install with: pip3 install pycryptodome", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Embedded server public key
|
|
SERVER_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
|
|
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA98imbEha/70cxkfXIbyJ
|
|
dbpM6y7X+MWMVcdSTwAeb+jzLRfKzMZVXeEaYzkzH+STlDiqmb+XufX+guhmpyKz
|
|
RbV3rcJeM2s4QJ3OcsbOnxVG9Eyo6LrHu/aU71LOW8pPD5eIh0/BRCDojG56pZ8N
|
|
CFF0Rsfve6SH6waOibUoovMYu2ZfzzGb/oeyPsL8yb4fIqnOOH85FbAm8aCrpGNZ
|
|
6A8U67s6TQAnqLFn6x2h901K2GhNxweRQqJ5n2qwMCPLmEHyKZiLo8GbK3lrcnbj
|
|
j6qNKkgoL+b9rcYJ1toLP8btTHIyQX2N+gWVgxzMNPtDfCKIJm4Jag0fIEiVsNF2
|
|
QZ7NaMjRqtRs8WIzmUWBMVoyWqWCty6sNrum/uYZNCcNma855IeFEOrmYNzNEWxP
|
|
5xgJT/Hvs9/bTOUWqrB3L/8EGeV3hrhUZ1km25mogAnSOcFWviqObREc/1yTfsB4
|
|
Ln0Hv4tvILd74aBqG1zRF0Wjd25O7vl8oY3RBp7iCtrq+CdOI1jrTx4yo9JM/DKu
|
|
MfTbFI1YPjuJahQjbbKHvCKPKq6pxCQBZ91SsBHdM2tHLRXZ1olO0y0trety+fjx
|
|
T9W1tmmRU9QyFvS9LsKdjfBOzyprC5MCz740pctKkSspYn88R19N8Cu3MihaTUw6
|
|
/JHvKMwL2+DgFmF7Dqjc+jsCAwEAAQ==
|
|
-----END PUBLIC KEY-----"""
|
|
|
|
VALIDATION_URL = "https://api.aliasrobotics.com:665/v1/validate"
|
|
KEY_INFO_URL = "https://api.aliasrobotics.com:666/key/info"
|
|
|
|
|
|
class APIValidationError(Exception):
|
|
pass
|
|
|
|
|
|
class CryptographyError(Exception):
|
|
pass
|
|
|
|
|
|
def _project_root() -> Path:
|
|
here = Path(__file__).resolve()
|
|
for cand in here.parents:
|
|
if (cand / "resources" / "keys").exists() or cand.name == "cai_installer":
|
|
return cand
|
|
return here.parent
|
|
|
|
|
|
ROOT = _project_root()
|
|
KEY_DIR = ROOT / "resources" / "keys"
|
|
ENC_FILE = KEY_DIR / "encrypted_api_key.enc"
|
|
KEY_FILE = KEY_DIR / "encryption_key.key"
|
|
|
|
|
|
def _ensure_command(cmd: str) -> None:
|
|
if shutil.which(cmd) is None:
|
|
raise APIValidationError(f"Required command '{cmd}' is not available on this system")
|
|
|
|
|
|
def _ensure_key_material() -> None:
|
|
KEY_DIR.mkdir(parents=True, exist_ok=True)
|
|
if not KEY_FILE.exists():
|
|
KEY_FILE.write_bytes(os.urandom(32))
|
|
os.chmod(KEY_FILE, 0o600)
|
|
|
|
|
|
def _openssl(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
_ensure_command("openssl")
|
|
proc = subprocess.run(["openssl", *args], capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
raise CryptographyError(proc.stderr.strip() or "OpenSSL command failed")
|
|
return proc
|
|
|
|
|
|
def save_encrypted_key(api_key: str) -> None:
|
|
if not isinstance(api_key, str) or not api_key.strip():
|
|
raise APIValidationError("API key cannot be empty")
|
|
_ensure_key_material()
|
|
with tempfile.NamedTemporaryFile("w", delete=False) as tmp_plain:
|
|
tmp_plain.write(api_key.strip())
|
|
tmp_plain_path = Path(tmp_plain.name)
|
|
try:
|
|
_openssl([
|
|
"enc",
|
|
"-aes-256-cbc",
|
|
"-pbkdf2",
|
|
"-salt",
|
|
"-pass",
|
|
f"file:{KEY_FILE}",
|
|
"-in",
|
|
str(tmp_plain_path),
|
|
"-out",
|
|
str(ENC_FILE),
|
|
])
|
|
finally:
|
|
try:
|
|
tmp_plain_path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
os.chmod(ENC_FILE, 0o600)
|
|
|
|
|
|
def load_encrypted_key() -> str:
|
|
if not ENC_FILE.exists():
|
|
raise CryptographyError(f"Encrypted key file not found: {ENC_FILE}")
|
|
if not KEY_FILE.exists():
|
|
raise CryptographyError(f"Encryption key file not found: {KEY_FILE}")
|
|
with tempfile.NamedTemporaryFile(delete=False) as tmp_plain:
|
|
tmp_plain_path = Path(tmp_plain.name)
|
|
try:
|
|
_openssl([
|
|
"enc",
|
|
"-d",
|
|
"-aes-256-cbc",
|
|
"-pbkdf2",
|
|
"-pass",
|
|
f"file:{KEY_FILE}",
|
|
"-in",
|
|
str(ENC_FILE),
|
|
"-out",
|
|
str(tmp_plain_path),
|
|
])
|
|
return tmp_plain_path.read_text().strip()
|
|
finally:
|
|
try:
|
|
tmp_plain_path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def generate_system_hash() -> str:
|
|
parts: list[str] = []
|
|
try:
|
|
nid = uuid.getnode()
|
|
if nid:
|
|
parts.append(f"mac:{nid:012x}")
|
|
except Exception:
|
|
pass
|
|
for p in (Path("/etc/machine-id"), Path("/var/lib/dbus/machine-id")):
|
|
try:
|
|
if p.exists():
|
|
val = p.read_text().strip()
|
|
if val:
|
|
parts.append(f"machine:{val}")
|
|
break
|
|
except Exception:
|
|
pass
|
|
for getter, label in ((platform.node, "node"), (platform.system, "system"), (platform.machine, "arch")):
|
|
try:
|
|
v = getter()
|
|
if v:
|
|
parts.append(f"{label}:{v}")
|
|
except Exception:
|
|
pass
|
|
if not parts:
|
|
rnd = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
|
|
parts.append("fallback:" + rnd)
|
|
digest = hashlib.sha256('|'.join(sorted(parts)).encode()).hexdigest()
|
|
return digest[:32]
|
|
|
|
|
|
def generate_nonce(n: int = 32) -> str:
|
|
alphabet = string.ascii_letters + string.digits
|
|
return ''.join(random.choice(alphabet) for _ in range(n))
|
|
|
|
|
|
def create_payload(api_key: str, system_hash: str, nonce: str) -> Dict[str, str]:
|
|
return {"key": api_key, "system": system_hash, "query": nonce}
|
|
|
|
|
|
def send_validation_request(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
_ensure_command("curl")
|
|
data = json.dumps(payload)
|
|
proc = subprocess.run([
|
|
"curl", "-sS", "--connect-timeout", "5", "--max-time", "5", "-k",
|
|
"-X", "GET",
|
|
"-H", "Content-Type: application/json",
|
|
"-d", data,
|
|
VALIDATION_URL
|
|
], capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
raise APIValidationError(proc.stderr.strip() or "Failed to contact validation server")
|
|
try:
|
|
return json.loads(proc.stdout.strip() or "{}")
|
|
except json.JSONDecodeError as exc:
|
|
raise APIValidationError(f"Invalid JSON response: {exc}")
|
|
|
|
|
|
def check_key_info_status(api_key: str) -> bool:
|
|
"""Confirm API key validity with the /key/info endpoint (HTTP 200 means valid)."""
|
|
_ensure_command("curl")
|
|
hdr = f"Authorization: Bearer {api_key}"
|
|
proc = subprocess.run([
|
|
"curl", "-sS", "--connect-timeout", "5", "--max-time", "5",
|
|
"-H", hdr,
|
|
"-w", "\n__STATUS__%{http_code}\n",
|
|
KEY_INFO_URL
|
|
], capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
raise APIValidationError(proc.stderr.strip() or "Failed to contact key info server")
|
|
out = proc.stdout
|
|
if "__STATUS__" in out:
|
|
body, _, tail = out.rpartition("__STATUS__")
|
|
code = tail.strip()
|
|
else:
|
|
code = "000"
|
|
return code == "200"
|
|
|
|
|
|
def verify_signature(signature_b64: str, message: str) -> bool:
|
|
"""
|
|
Verify RSA signature using server's public key.
|
|
Returns True if signature is valid for the given message.
|
|
"""
|
|
try:
|
|
# Load public key
|
|
public_key = RSA.import_key(SERVER_PUBLIC_KEY_PEM)
|
|
|
|
# Decode signature
|
|
signature_bytes = base64.b64decode(signature_b64)
|
|
|
|
# Hash the message
|
|
msg_hash = SHA256.new(message.encode('utf-8'))
|
|
|
|
# Verify signature
|
|
pkcs1_15.new(public_key).verify(msg_hash, signature_bytes)
|
|
return True
|
|
except Exception:
|
|
# Signature verification failed
|
|
return False
|
|
|
|
|
|
def validate_encrypted_api_key() -> bool:
|
|
api_key = load_encrypted_key()
|
|
system_hash = generate_system_hash()
|
|
nonce = generate_nonce()
|
|
|
|
# Send validation request to server
|
|
response = send_validation_request(create_payload(api_key, system_hash, nonce))
|
|
|
|
if not isinstance(response, dict):
|
|
raise APIValidationError("Validation server returned unexpected payload")
|
|
|
|
# Get signature from response
|
|
signature = response.get("validation")
|
|
if not signature or not isinstance(signature, str):
|
|
raise APIValidationError("Server response missing 'validation' signature")
|
|
|
|
# Construct expected message with "true" (server signs: system_hash + nonce + "true"/"false")
|
|
expected_message_true = system_hash + nonce + "true"
|
|
|
|
# Verify cryptographic signature
|
|
if not verify_signature(signature, expected_message_true):
|
|
raise APIValidationError("API key rejected: signature verification failed (hardware mismatch or invalid key)")
|
|
|
|
# Additional check: verify key is valid via /key/info endpoint
|
|
if not check_key_info_status(api_key):
|
|
raise APIValidationError("API key validation failed (key/info rejected or invalid)")
|
|
|
|
return True
|
|
|
|
|
|
def _main(argv: list[str]) -> int:
|
|
if len(argv) != 2:
|
|
print("Error: API key not provided")
|
|
return 1
|
|
key = argv[1]
|
|
try:
|
|
print("Encrypting and saving API key...")
|
|
save_encrypted_key(key)
|
|
print("API key encrypted and saved successfully.")
|
|
print("Validating API key...")
|
|
if validate_encrypted_api_key():
|
|
print("API key validation successful.")
|
|
return 0
|
|
except (CryptographyError, APIValidationError) as exc:
|
|
print(f"Error: {exc}")
|
|
return 1
|
|
except Exception as exc:
|
|
print(f"Unexpected error: {exc}")
|
|
return 1
|
|
print("API key validation failed.")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(_main(sys.argv))
|