Initial commit: VMware vSphere API simulator scaffold.
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""Password, ticket, CSRF, and API-token primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
class AuthenticationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _b64(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _unb64(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def hash_secret(secret: str, *, salt: bytes | None = None) -> str:
|
||||
actual_salt = salt or secrets.token_bytes(16)
|
||||
digest = hashlib.scrypt(secret.encode(), salt=actual_salt, n=2**14, r=8, p=1, dklen=32)
|
||||
return f"scrypt$16384$8$1${_b64(actual_salt)}${_b64(digest)}"
|
||||
|
||||
|
||||
def verify_secret(secret: str, encoded: str) -> bool:
|
||||
try:
|
||||
algorithm, n, r, p, salt, expected = encoded.split("$")
|
||||
if algorithm != "scrypt":
|
||||
return False
|
||||
actual = hashlib.scrypt(
|
||||
secret.encode(), salt=_unb64(salt), n=int(n), r=int(r), p=int(p), dklen=32
|
||||
)
|
||||
return hmac.compare_digest(actual, _unb64(expected))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketClaims:
|
||||
principal: str
|
||||
issued_at: int
|
||||
expires_at: int
|
||||
nonce: str
|
||||
|
||||
|
||||
def issue_ticket(principal: str, key: bytes, *, now: int | None = None, ttl: int = 7200) -> str:
|
||||
issued = int(time.time() if now is None else now)
|
||||
claims = {
|
||||
"exp": issued + ttl,
|
||||
"iat": issued,
|
||||
"nonce": _b64(secrets.token_bytes(12)),
|
||||
"principal": principal,
|
||||
}
|
||||
payload = _b64(json.dumps(claims, sort_keys=True, separators=(",", ":")).encode())
|
||||
signature = _b64(hmac.digest(key, payload.encode(), "sha256"))
|
||||
return f"PVE:{payload}.{signature}"
|
||||
|
||||
|
||||
def verify_ticket(ticket: str, key: bytes, *, now: int | None = None) -> TicketClaims:
|
||||
try:
|
||||
prefix, signed = ticket.split(":", 1)
|
||||
payload, signature = signed.split(".", 1)
|
||||
if prefix != "PVE" or not hmac.compare_digest(
|
||||
_unb64(signature), hmac.digest(key, payload.encode(), "sha256")
|
||||
):
|
||||
raise AuthenticationError("invalid ticket")
|
||||
data = json.loads(_unb64(payload))
|
||||
claims = TicketClaims(
|
||||
principal=str(data["principal"]),
|
||||
issued_at=int(data["iat"]),
|
||||
expires_at=int(data["exp"]),
|
||||
nonce=str(data["nonce"]),
|
||||
)
|
||||
except (ValueError, KeyError, json.JSONDecodeError) as error:
|
||||
raise AuthenticationError("invalid ticket") from error
|
||||
current = int(time.time() if now is None else now)
|
||||
if claims.expires_at < current or claims.issued_at > current + 60:
|
||||
raise AuthenticationError("ticket expired or not yet valid")
|
||||
return claims
|
||||
|
||||
|
||||
def csrf_token(ticket: str, key: bytes) -> str:
|
||||
return _b64(hmac.digest(key, b"csrf:" + ticket.encode(), "sha256"))
|
||||
|
||||
|
||||
def verify_csrf(ticket: str, token: str, key: bytes) -> bool:
|
||||
return hmac.compare_digest(csrf_token(ticket, key), token)
|
||||
|
||||
|
||||
def set_ticket_cookie(response: Response, ticket: str, *, secure: bool = True) -> None:
|
||||
response.set_cookie(
|
||||
"PVEAuthCookie",
|
||||
ticket,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite="strict",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApiToken:
|
||||
principal: str
|
||||
token_id: str
|
||||
secret: str
|
||||
|
||||
|
||||
TOKEN_PATTERN = re.compile(r"^PVEAPIToken=([^!=\s]+![^=\s]+)=([^\s]+)$")
|
||||
|
||||
|
||||
def parse_api_token(header: str) -> ApiToken:
|
||||
match = TOKEN_PATTERN.fullmatch(header)
|
||||
if match is None:
|
||||
raise AuthenticationError("invalid API token")
|
||||
identity, secret = match.groups()
|
||||
principal, token_id = identity.rsplit("!", 1)
|
||||
return ApiToken(principal, token_id, secret)
|
||||
|
||||
|
||||
SECRET_RE = re.compile(r"(PVEAPIToken=[^=\s]+=)[^\s]+|(password|secret|token)=([^&\s]+)", re.I)
|
||||
|
||||
|
||||
def redact_secrets(value: str) -> str:
|
||||
return SECRET_RE.sub(
|
||||
lambda match: (match.group(1) or f"{match.group(2)}=") + "[REDACTED]", value
|
||||
)
|
||||
Reference in New Issue
Block a user