From d35823df08063710a6aaf579598e8d4b40b2795c Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 2 Mar 2026 01:05:50 +0100 Subject: [PATCH] =?UTF-8?q?feat(local-identity):=20Stage=203=20=E2=80=94?= =?UTF-8?q?=20minimal=20native=20OIDC=20provider=20(NK-WP-0002-T03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add local-identity serve command: a minimal Authorization Code flow OIDC server backed by file-store users. Implemented natively with no heavy OIDC library — only stdlib http.server and the cryptography package. New modules: keys.py RSA-2048 signing key generation + JWKS helpers tls.py Self-signed TLS certificate (localhost/127.0.0.1 SANs) jwt_utils.py RS256 JWT creation and verification serve.py OIDCHandler + make_handler() factory + run_server() Endpoints: /.well-known/openid-configuration, /jwks, /auth, /token, /userinfo. Server binds to 127.0.0.1 only; tokens carry iss: local-identity which production Keycloak rejects by design. 104 tests passing (16 new for Stage 3). Co-Authored-By: Claude Sonnet 4.6 --- local-identity/pyproject.toml | 1 + local-identity/src/local_identity/cli.py | 20 + .../src/local_identity/jwt_utils.py | 116 +++++ local-identity/src/local_identity/keys.py | 74 +++ local-identity/src/local_identity/serve.py | 462 ++++++++++++++++++ local-identity/src/local_identity/tls.py | 78 +++ local-identity/tests/test_serve.py | 439 +++++++++++++++++ local-identity/uv.lock | 144 +++++- workplans/NK-WP-0002-local-identity.md | 5 +- 9 files changed, 1336 insertions(+), 3 deletions(-) create mode 100644 local-identity/src/local_identity/jwt_utils.py create mode 100644 local-identity/src/local_identity/keys.py create mode 100644 local-identity/src/local_identity/serve.py create mode 100644 local-identity/src/local_identity/tls.py create mode 100644 local-identity/tests/test_serve.py diff --git a/local-identity/pyproject.toml b/local-identity/pyproject.toml index 533ad34..c0ff55c 100644 --- a/local-identity/pyproject.toml +++ b/local-identity/pyproject.toml @@ -5,6 +5,7 @@ description = "Zero-dependency bootstrap user store for net-kingdom environments requires-python = ">=3.11" dependencies = [ "pyyaml>=6.0", + "cryptography>=42.0", ] [project.scripts] diff --git a/local-identity/src/local_identity/cli.py b/local-identity/src/local_identity/cli.py index 3f984a2..222370c 100644 --- a/local-identity/src/local_identity/cli.py +++ b/local-identity/src/local_identity/cli.py @@ -11,6 +11,7 @@ Commands: export [] Export a single user as Keycloak JSON. export --all [--realm R] Bulk partial-import body (primary users only). Add --include-test to include generated users. + serve [--port P] [--ttl T] Start the minimal OIDC server on 127.0.0.1. Environment: LOCAL_IDENTITY_HOME Override the store directory (default: ~/.local-identity). @@ -23,6 +24,7 @@ import sys from .gecos import current_username, get_gecos_fullname from .user import UserRecord, make_test_user from . import export as export_mod +from . import serve as serve_mod from . import store @@ -133,6 +135,10 @@ def cmd_show(args: argparse.Namespace) -> None: print(user.to_yaml(), end="") +def cmd_serve(args: argparse.Namespace) -> None: + serve_mod.run_server(port=args.port, token_ttl=args.ttl) + + def main() -> None: parser = argparse.ArgumentParser( prog="local-identity", @@ -196,6 +202,20 @@ def main() -> None: p_show.add_argument("username", help="Username to display") p_show.set_defaults(func=cmd_show) + p_serve = sub.add_parser( + "serve", + help="Start the minimal OIDC server (127.0.0.1 only)", + ) + p_serve.add_argument( + "--port", type=int, default=8443, + help="Port to listen on (default: 8443)", + ) + p_serve.add_argument( + "--ttl", type=int, default=3600, + help="Token TTL in seconds (default: 3600)", + ) + p_serve.set_defaults(func=cmd_serve) + args = parser.parse_args() args.func(args) diff --git a/local-identity/src/local_identity/jwt_utils.py b/local-identity/src/local_identity/jwt_utils.py new file mode 100644 index 0000000..4c8cf52 --- /dev/null +++ b/local-identity/src/local_identity/jwt_utils.py @@ -0,0 +1,116 @@ +""" +JWT creation and verification for local-identity OIDC serve. + +Uses RS256 (RSA-PKCS1v15 + SHA-256) signing via the cryptography library. +No third-party JWT library is used — only stdlib base64/json and cryptography +primitives. +""" + +import base64 +import json +import time +import uuid + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey + + +# ------------------------------------------------------------------ # +# Base64url helpers # +# ------------------------------------------------------------------ # + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(s: str) -> bytes: + pad = (4 - len(s) % 4) % 4 + return base64.urlsafe_b64decode(s + "=" * pad) + + +# ------------------------------------------------------------------ # +# Token creation # +# ------------------------------------------------------------------ # + +def create_token( + private_key: RSAPrivateKey, + kid: str, + sub: str, + iss: str, + aud: str, + email: str, + name: str, + preferred_username: str, + ttl: int = 3600, + nonce: str | None = None, +) -> str: + """Create and sign a JWT with RS256. Returns the compact serialisation.""" + now = int(time.time()) + header = {"alg": "RS256", "typ": "JWT", "kid": kid} + payload: dict = { + "sub": sub, + "iss": iss, + "aud": aud, + "exp": now + ttl, + "iat": now, + "jti": str(uuid.uuid4()), + "email": email, + "name": name, + "preferred_username": preferred_username, + } + if nonce is not None: + payload["nonce"] = nonce + + header_b64 = _b64url_encode(json.dumps(header, separators=(",", ":")).encode()) + payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + + signature = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()) + return f"{header_b64}.{payload_b64}.{_b64url_encode(signature)}" + + +# ------------------------------------------------------------------ # +# Token verification # +# ------------------------------------------------------------------ # + +class JWTError(Exception): + pass + + +def verify_token(token: str, public_key: RSAPublicKey) -> dict: + """ + Verify signature and expiry of a JWT. Returns the decoded payload. + Raises JWTError for any failure (malformed, bad signature, expired). + """ + parts = token.split(".") + if len(parts) != 3: + raise JWTError("malformed token: expected 3 parts") + + header_b64, payload_b64, sig_b64 = parts + + try: + header = json.loads(_b64url_decode(header_b64)) + except Exception as exc: + raise JWTError(f"cannot decode header: {exc}") from exc + + if header.get("alg") != "RS256": + raise JWTError(f"unsupported algorithm: {header.get('alg')!r}") + + try: + payload = json.loads(_b64url_decode(payload_b64)) + except Exception as exc: + raise JWTError(f"cannot decode payload: {exc}") from exc + + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + try: + sig_bytes = _b64url_decode(sig_b64) + public_key.verify(sig_bytes, signing_input, padding.PKCS1v15(), hashes.SHA256()) + except InvalidSignature as exc: + raise JWTError("invalid signature") from exc + + if "exp" in payload and int(time.time()) > payload["exp"]: + raise JWTError("token has expired") + + return payload diff --git a/local-identity/src/local_identity/keys.py b/local-identity/src/local_identity/keys.py new file mode 100644 index 0000000..a32896a --- /dev/null +++ b/local-identity/src/local_identity/keys.py @@ -0,0 +1,74 @@ +""" +RSA signing key management for local-identity OIDC serve. + +The signing key is generated on first invocation and stored at: + ~/.local-identity/keys/signing_private.pem (mode 600) + +The corresponding public key is never stored separately — it is always +derived from the private key on load. +""" + +import base64 +import hashlib +import os +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + +from .store import _store_dir + + +def _keys_dir() -> Path: + return _store_dir() / "keys" + + +def ensure_signing_key() -> RSAPrivateKey: + """Load the signing key from disk, or generate a new RSA-2048 key if absent.""" + keys_dir = _keys_dir() + keys_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + + priv_path = keys_dir / "signing_private.pem" + + if priv_path.exists(): + priv_pem = priv_path.read_bytes() + return serialization.load_pem_private_key(priv_pem, password=None) + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + priv_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + priv_path.write_bytes(priv_pem) + os.chmod(priv_path, 0o600) + + return private_key + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def key_id(private_key: RSAPrivateKey) -> str: + """Return a stable 16-hex-char key ID derived from the public key modulus.""" + n = private_key.public_key().public_numbers().n + n_bytes = n.to_bytes((n.bit_length() + 7) // 8, byteorder="big") + return hashlib.sha256(n_bytes).hexdigest()[:16] + + +def jwk_public(private_key: RSAPrivateKey) -> dict: + """Return the RSA public key as a JWK dict (RS256, sig use).""" + pub = private_key.public_key().public_numbers() + n_bytes = pub.n.to_bytes((pub.n.bit_length() + 7) // 8, byteorder="big") + e_bytes = pub.e.to_bytes((pub.e.bit_length() + 7) // 8, byteorder="big") + return { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": key_id(private_key), + "n": _b64url(n_bytes), + "e": _b64url(e_bytes), + } diff --git a/local-identity/src/local_identity/serve.py b/local-identity/src/local_identity/serve.py new file mode 100644 index 0000000..880fb99 --- /dev/null +++ b/local-identity/src/local_identity/serve.py @@ -0,0 +1,462 @@ +""" +Minimal OIDC Authorization Code flow server for local-identity. + +Binds to 127.0.0.1 only — external binding is explicitly unsupported. +In production mode (run_server) the socket is wrapped with TLS using an +auto-generated self-signed certificate. In test mode callers can use +make_handler() with scheme="http" and a plain HTTPServer. + +Endpoints +--------- + GET /.well-known/openid-configuration OIDC discovery document + GET /jwks JSON Web Key Set + GET /auth Authorization endpoint — login form + POST /auth Form submission — user selection + POST /token Token endpoint + GET /userinfo UserInfo endpoint (Bearer token) + +Design constraints (from NK-WP-0002) +-------------------------------------- + * No client secret validation (all registered clients trusted — dev only) + * No refresh tokens (stateless; re-auth required after expiry) + * Scopes supported: openid, profile, email + * iss: "local-identity" — intentionally non-routable; production Keycloak + rejects tokens with this issuer by design +""" + +import http.server +import json +import secrets +import ssl +import sys +import time +import urllib.parse +from http import HTTPStatus +from pathlib import Path + +from . import store +from .jwt_utils import JWTError, create_token, verify_token +from .keys import ensure_signing_key, jwk_public, key_id +from .tls import ensure_tls_cert + +_BIND_HOST = "127.0.0.1" +_ISSUER = "local-identity" +_CODE_TTL = 60 # auth codes expire after 60 seconds + + +# ------------------------------------------------------------------ # +# HTML helpers # +# ------------------------------------------------------------------ # + +def _he(s: str) -> str: + """Minimal HTML attribute escaping.""" + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +# ------------------------------------------------------------------ # +# Handler factory # +# ------------------------------------------------------------------ # + +def make_handler( + private_key, + kid: str, + token_ttl: int = 3600, + scheme: str = "https", +): + """ + Return a configured OIDCHandler subclass. + + Each call produces a fresh class with its own isolated auth-code store, + so multiple test servers can run concurrently without sharing state. + """ + codes: dict = {} + + class _Handler(OIDCHandler): + _private_key = private_key + _kid = kid + _token_ttl = token_ttl + _codes = codes + _scheme = scheme + + return _Handler + + +# ------------------------------------------------------------------ # +# HTTP handler # +# ------------------------------------------------------------------ # + +class OIDCHandler(http.server.BaseHTTPRequestHandler): + # Configured by make_handler() / run_server() via subclass class vars + _private_key = None + _kid: str = "" + _token_ttl: int = 3600 + _codes: dict = {} + _scheme: str = "https" + + def log_message(self, fmt: str, *args) -> None: + pass # silence default Apache-style logging + + # ---------------------------------------------------------------- # + # Routing # + # ---------------------------------------------------------------- # + + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + if path == "/.well-known/openid-configuration": + self._handle_discovery() + elif path == "/jwks": + self._handle_jwks() + elif path == "/auth": + self._handle_auth_get(parsed) + elif path == "/userinfo": + self._handle_userinfo() + else: + self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: + path = urllib.parse.urlparse(self.path).path + if path == "/auth": + self._handle_auth_post() + elif path == "/token": + self._handle_token() + else: + self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND) + + # ---------------------------------------------------------------- # + # Endpoint: OIDC discovery # + # ---------------------------------------------------------------- # + + def _handle_discovery(self) -> None: + base = self._base_url() + doc = { + "issuer": _ISSUER, + "authorization_endpoint": f"{base}/auth", + "token_endpoint": f"{base}/token", + "userinfo_endpoint": f"{base}/userinfo", + "jwks_uri": f"{base}/jwks", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid", "profile", "email"], + "token_endpoint_auth_methods_supported": ["none"], + "grant_types_supported": ["authorization_code"], + "claims_supported": [ + "sub", "iss", "aud", "exp", "iat", + "email", "name", "preferred_username", + ], + } + self._send_json(doc) + + # ---------------------------------------------------------------- # + # Endpoint: JWKS # + # ---------------------------------------------------------------- # + + def _handle_jwks(self) -> None: + self._send_json({"keys": [jwk_public(self._private_key)]}) + + # ---------------------------------------------------------------- # + # Endpoint: GET /auth — display login form # + # ---------------------------------------------------------------- # + + def _handle_auth_get(self, parsed: urllib.parse.ParseResult) -> None: + params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) + + if params.get("response_type", [""])[0] != "code": + self._send_error_redirect( + params.get("redirect_uri", [""])[0], + "unsupported_response_type", + params.get("state", [""])[0], + ) + return + + users = store.list_users() + if not users: + self._send_html( + "

Error

No users in store. Run " + "local-identity init first.

", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + client_id = params.get("client_id", [""])[0] + redirect_uri = params.get("redirect_uri", [""])[0] + state = params.get("state", [""])[0] + nonce = params.get("nonce", [""])[0] + + options = "\n".join( + f'" + for u in users + ) + + form = f""" + + + + local-identity login + + + +

local-identity dev login

+

Dev/test only. Tokens carry + iss: local-identity — production systems reject them.

+

Authenticating for {_he(client_id) or "(unknown client)"}

+
+ + + + + + + +
+ +""" + self._send_html(form) + + # ---------------------------------------------------------------- # + # Endpoint: POST /auth — process login form # + # ---------------------------------------------------------------- # + + def _handle_auth_post(self) -> None: + body = self._read_form_body() + username = body.get("username", [""])[0] + client_id = body.get("client_id", [""])[0] + redirect_uri = body.get("redirect_uri", [""])[0] + state = body.get("state", [""])[0] + nonce = body.get("nonce", [""])[0] + + if not redirect_uri: + self._send_json( + {"error": "invalid_request", "error_description": "missing redirect_uri"}, + HTTPStatus.BAD_REQUEST, + ) + return + + try: + store.read_user(username) + except FileNotFoundError: + self._send_error_redirect(redirect_uri, "access_denied", state) + return + + # Expire stale codes on each write + now = time.time() + stale = [c for c, v in self._codes.items() if v["expires_at"] < now] + for c in stale: + del self._codes[c] + + code = secrets.token_urlsafe(32) + self._codes[code] = { + "username": username, + "client_id": client_id, + "redirect_uri": redirect_uri, + "nonce": nonce or None, + "expires_at": now + _CODE_TTL, + } + + sep = "&" if "?" in redirect_uri else "?" + location = ( + f"{redirect_uri}{sep}code={urllib.parse.quote(code)}" + f"&state={urllib.parse.quote(state)}" + ) + self.send_response(HTTPStatus.FOUND) + self.send_header("Location", location) + self.end_headers() + + # ---------------------------------------------------------------- # + # Endpoint: POST /token # + # ---------------------------------------------------------------- # + + def _handle_token(self) -> None: + body = self._read_form_body() + grant_type = body.get("grant_type", [""])[0] + + if grant_type != "authorization_code": + self._send_json( + {"error": "unsupported_grant_type"}, + HTTPStatus.BAD_REQUEST, + ) + return + + code = body.get("code", [""])[0] + client_id = body.get("client_id", [""])[0] + + code_data = self._codes.pop(code, None) + if code_data is None: + self._send_json( + {"error": "invalid_grant", "error_description": "unknown or expired code"}, + HTTPStatus.BAD_REQUEST, + ) + return + + if time.time() > code_data["expires_at"]: + self._send_json( + {"error": "invalid_grant", "error_description": "code expired"}, + HTTPStatus.BAD_REQUEST, + ) + return + + try: + user = store.read_user(code_data["username"]) + except FileNotFoundError: + self._send_json( + {"error": "invalid_grant", "error_description": "user not found"}, + HTTPStatus.BAD_REQUEST, + ) + return + + token = create_token( + private_key=self._private_key, + kid=self._kid, + sub=user.username, + iss=_ISSUER, + aud=code_data.get("client_id") or client_id or "local-identity", + email=user.email, + name=user.fullname, + preferred_username=user.username, + ttl=self._token_ttl, + nonce=code_data.get("nonce"), + ) + + self._send_json({ + "access_token": token, + "id_token": token, + "token_type": "Bearer", + "expires_in": self._token_ttl, + }) + + # ---------------------------------------------------------------- # + # Endpoint: GET /userinfo # + # ---------------------------------------------------------------- # + + def _handle_userinfo(self) -> None: + auth_header = self.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + self.send_response(HTTPStatus.UNAUTHORIZED) + self.send_header("WWW-Authenticate", 'Bearer realm="local-identity"') + self.end_headers() + return + + token = auth_header[len("Bearer "):] + try: + payload = verify_token(token, self._private_key.public_key()) + except JWTError as exc: + self._send_json( + {"error": "invalid_token", "error_description": str(exc)}, + HTTPStatus.UNAUTHORIZED, + ) + return + + self._send_json({ + "sub": payload["sub"], + "email": payload.get("email"), + "name": payload.get("name"), + "preferred_username": payload.get("preferred_username"), + }) + + # ---------------------------------------------------------------- # + # Helpers # + # ---------------------------------------------------------------- # + + def _base_url(self) -> str: + host, port = self.server.server_address + return f"{self._scheme}://{host}:{port}" + + def _read_form_body(self) -> dict: + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length).decode("utf-8") + return urllib.parse.parse_qs(raw, keep_blank_values=True) + + def _send_json(self, data: dict, status: HTTPStatus = HTTPStatus.OK) -> None: + body = json.dumps(data).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_html(self, html: str, status: HTTPStatus = HTTPStatus.OK) -> None: + body = html.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_error_redirect( + self, redirect_uri: str, error: str, state: str + ) -> None: + if not redirect_uri: + self._send_json({"error": error}, HTTPStatus.BAD_REQUEST) + return + sep = "&" if "?" in redirect_uri else "?" + location = ( + f"{redirect_uri}{sep}error={urllib.parse.quote(error)}" + f"&state={urllib.parse.quote(state)}" + ) + self.send_response(HTTPStatus.FOUND) + self.send_header("Location", location) + self.end_headers() + + +# ------------------------------------------------------------------ # +# Public entry point # +# ------------------------------------------------------------------ # + +def run_server(port: int = 8443, token_ttl: int = 3600) -> None: + """ + Start the local-identity OIDC server on 127.0.0.1:{port} with TLS. + Blocks until Ctrl+C. + + The server always binds to 127.0.0.1. External binding (0.0.0.0) is + not supported and is not offered as an option. + """ + if not store.store_exists(): + print( + "Error: store not initialised. Run 'local-identity init' first.", + file=sys.stderr, + ) + sys.exit(1) + + private_key = ensure_signing_key() + cert_path, key_path = ensure_tls_cert() + + HandlerClass = make_handler(private_key, key_id(private_key), token_ttl, scheme="https") + + httpd = http.server.HTTPServer((_BIND_HOST, port), HandlerClass) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(str(cert_path), str(key_path)) + httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) + + base_url = f"https://{_BIND_HOST}:{port}" + print(f"local-identity OIDC server") + print(f" URL: {base_url}") + print(f" Discovery: {base_url}/.well-known/openid-configuration") + print(f" Token TTL: {token_ttl}s") + print(f" Bound to: {_BIND_HOST} (localhost only)") + print("Press Ctrl+C to stop.") + + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.server_close() diff --git a/local-identity/src/local_identity/tls.py b/local-identity/src/local_identity/tls.py new file mode 100644 index 0000000..0fa8b33 --- /dev/null +++ b/local-identity/src/local_identity/tls.py @@ -0,0 +1,78 @@ +""" +Self-signed TLS certificate management for local-identity serve. + +Certificate and key are generated on first run and stored at: + ~/.local-identity/tls/server.crt (mode 644, 10-year validity) + ~/.local-identity/tls/server.key (mode 600) + +The certificate covers localhost and 127.0.0.1 via SubjectAltName. +""" + +import datetime +import ipaddress +import os +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +from .store import _store_dir + + +def _tls_dir() -> Path: + return _store_dir() / "tls" + + +def ensure_tls_cert() -> tuple[Path, Path]: + """Ensure a self-signed TLS cert exists. Returns (cert_path, key_path).""" + tls_dir = _tls_dir() + tls_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + + cert_path = tls_dir / "server.crt" + key_path = tls_dir / "server.key" + + if not cert_path.exists() or not key_path.exists(): + _generate_cert(cert_path, key_path) + + return cert_path, key_path + + +def _generate_cert(cert_path: Path, key_path: Path) -> None: + tls_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + key_path.write_bytes( + tls_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + os.chmod(key_path, 0o600) + + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "localhost"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "local-identity"), + ]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(tls_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName([ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), + ]), + critical=False, + ) + .sign(tls_key, hashes.SHA256()) + ) + + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + os.chmod(cert_path, 0o644) diff --git a/local-identity/tests/test_serve.py b/local-identity/tests/test_serve.py new file mode 100644 index 0000000..63ed6cb --- /dev/null +++ b/local-identity/tests/test_serve.py @@ -0,0 +1,439 @@ +""" +Tests for local-identity OIDC serve module. + +The tests spin up a plain HTTP (non-TLS) server on a random port using +make_handler() with scheme="http". This keeps tests fast and free of +certificate trust issues while exercising all the OIDC logic. +""" + +import http.server +import json +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +import pytest + +from local_identity import store +from local_identity.jwt_utils import JWTError, create_token, verify_token +from local_identity.keys import ensure_signing_key, jwk_public, key_id +from local_identity.serve import _BIND_HOST, _ISSUER, make_handler +from local_identity.user import UserRecord, make_test_user + + +# ------------------------------------------------------------------ # +# Fixtures # +# ------------------------------------------------------------------ # + +@pytest.fixture +def populated_store(tmp_store): + """Init store with primary user alice + two test users.""" + store.init_dirs() + primary = UserRecord( + username="alice", + fullname="Alice Smith", + email="alice@example.com", + ) + store.write_user(primary) + store.write_user(make_test_user(primary, 1)) + store.write_user(make_test_user(primary, 2)) + return primary + + +@pytest.fixture +def oidc_server(populated_store, tmp_store): + """ + Start a plain HTTP OIDC server on a random port. + Yields (base_url, private_key). + """ + private_key = ensure_signing_key() + kid = key_id(private_key) + HandlerClass = make_handler(private_key, kid, token_ttl=60, scheme="http") + + httpd = http.server.HTTPServer((_BIND_HOST, 0), HandlerClass) + port = httpd.server_address[1] + + thread = threading.Thread(target=httpd.serve_forever) + thread.daemon = True + thread.start() + + yield f"http://{_BIND_HOST}:{port}", private_key + + httpd.shutdown() + + +# ------------------------------------------------------------------ # +# Helpers # +# ------------------------------------------------------------------ # + +def _get_json(url: str) -> dict: + with urllib.request.urlopen(url) as resp: + return json.loads(resp.read()) + + +def _post_form(url: str, fields: dict) -> tuple[int, dict]: + data = urllib.parse.urlencode(fields).encode() + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read()) + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Opener that surfaces redirects as HTTPError instead of following them.""" + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def _post_form_no_redirect(url: str, fields: dict) -> tuple[int, str]: + """POST a form, capture the Location header without following the redirect.""" + data = urllib.parse.urlencode(fields).encode() + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + opener = urllib.request.build_opener(_NoRedirect()) + try: + opener.open(req) + return 200, "" + except urllib.error.HTTPError as e: + return e.code, e.headers.get("Location", "") + + +def _do_auth_code_flow(base_url: str, username: str = "alice") -> dict: + """ + Execute the full authorization code flow for the given username. + Returns the token response dict. + """ + callback = "http://localhost:9999/callback" + # POST /auth (skip GET — form is cosmetic only) + status, location = _post_form_no_redirect( + f"{base_url}/auth", + { + "username": username, + "client_id": "testclient", + "redirect_uri": callback, + "state": "s1", + "nonce": "n1", + }, + ) + assert status == 302, f"Expected 302, got {status}" + assert "code=" in location, f"No code in redirect: {location}" + + qs = urllib.parse.parse_qs(urllib.parse.urlparse(location).query) + code = qs["code"][0] + + _, token_resp = _post_form( + f"{base_url}/token", + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": callback, + "client_id": "testclient", + }, + ) + return token_resp + + +# ------------------------------------------------------------------ # +# OIDC discovery # +# ------------------------------------------------------------------ # + +def test_discovery_structure(oidc_server): + base_url, _ = oidc_server + doc = _get_json(f"{base_url}/.well-known/openid-configuration") + + assert doc["issuer"] == _ISSUER + for key in ("authorization_endpoint", "token_endpoint", + "userinfo_endpoint", "jwks_uri"): + assert doc[key].startswith(base_url), f"{key} should point to server" + + assert "code" in doc["response_types_supported"] + assert "RS256" in doc["id_token_signing_alg_values_supported"] + assert "openid" in doc["scopes_supported"] + + +# ------------------------------------------------------------------ # +# JWKS # +# ------------------------------------------------------------------ # + +def test_jwks_contains_rsa_key(oidc_server): + base_url, private_key = oidc_server + data = _get_json(f"{base_url}/jwks") + + assert "keys" in data + assert len(data["keys"]) == 1 + jwk = data["keys"][0] + assert jwk["kty"] == "RSA" + assert jwk["alg"] == "RS256" + assert jwk["use"] == "sig" + assert "n" in jwk and "e" in jwk + assert jwk["kid"] == key_id(private_key) + + +# ------------------------------------------------------------------ # +# Authorization endpoint (GET) # +# ------------------------------------------------------------------ # + +def test_auth_get_returns_html_form(oidc_server): + base_url, _ = oidc_server + params = urllib.parse.urlencode({ + "response_type": "code", + "client_id": "testclient", + "redirect_uri": "http://localhost:9999/callback", + "scope": "openid profile email", + "state": "xyz", + }) + with urllib.request.urlopen(f"{base_url}/auth?{params}") as resp: + body = resp.read().decode() + + assert " int(time.time()) + + +def test_jwt_expired_rejected(tmp_store): + store.init_dirs() + private_key = ensure_signing_key() + kid = key_id(private_key) + + token = create_token( + private_key=private_key, + kid=kid, + sub="bob", + iss=_ISSUER, + aud="x", + email="b@x.com", + name="Bob", + preferred_username="bob", + ttl=-1, # already expired + ) + + with pytest.raises(JWTError, match="expired"): + verify_token(token, private_key.public_key()) + + +def test_jwt_signing_key_persisted(tmp_store): + """ensure_signing_key() returns the same key on repeated calls.""" + store.init_dirs() + k1 = ensure_signing_key() + k2 = ensure_signing_key() + assert key_id(k1) == key_id(k2) diff --git a/local-identity/uv.lock b/local-identity/uv.lock index 3620391..d0a076b 100644 --- a/local-identity/uv.lock +++ b/local-identity/uv.lock @@ -1,6 +1,76 @@ version = 1 requires-python = ">=3.11" +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -10,6 +80,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289 }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637 }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742 }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528 }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993 }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855 }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635 }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038 }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181 }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482 }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497 }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819 }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230 }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909 }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287 }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728 }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287 }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291 }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539 }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199 }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131 }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072 }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170 }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741 }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728 }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001 }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637 }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487 }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514 }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349 }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667 }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980 }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143 }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674 }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801 }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755 }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539 }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794 }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160 }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123 }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220 }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050 }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964 }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321 }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786 }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990 }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252 }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605 }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -24,6 +153,7 @@ name = "local-identity" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "pyyaml" }, ] @@ -33,7 +163,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] +requires-dist = [ + { name = "cryptography", specifier = ">=42.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8.0" }] @@ -56,6 +189,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pygments" version = "2.19.2" diff --git a/workplans/NK-WP-0002-local-identity.md b/workplans/NK-WP-0002-local-identity.md index ded7038..48413d0 100644 --- a/workplans/NK-WP-0002-local-identity.md +++ b/workplans/NK-WP-0002-local-identity.md @@ -144,8 +144,9 @@ Keycloak dev instance. ```task id: NK-WP-0002-T03 state_hub_task_id: eb09d287-8e08-4c88-8bd1-6f0501ef5fc8 -status: todo +status: done priority: medium +commit: (pending) ``` Implement `local-identity serve` — a minimal OIDC Authorization Code flow @@ -225,7 +226,7 @@ expiry and revocation functional. - [x] `~/.local-identity/` store initialised from Linux identity; test users generated - [x] `local-identity list / show / export` working; Keycloak export validated -- [ ] Minimal OIDC server passes conformance smoke test; binds localhost only +- [x] Minimal OIDC server passes conformance smoke test; binds localhost only - [ ] Filesystem permissions enforced on startup; `security-check` passes - [ ] Audit log recording all auth events - [ ] `docs/LocalIdentity.md` complete with import procedure and security model