generated from coulomb/repo-seed
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>
This commit is contained in:
74
local-identity/src/local_identity/keys.py
Normal file
74
local-identity/src/local_identity/keys.py
Normal file
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user