Files
net-kingdom/local-identity/src/local_identity/tls.py
tegwick d35823df08 feat(local-identity): Stage 3 — minimal native OIDC provider (NK-WP-0002-T03)
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 <noreply@anthropic.com>
2026-03-02 01:05:50 +01:00

79 lines
2.4 KiB
Python

"""
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)