Files
net-kingdom/local-identity/src/local_identity/keys.py
tegwick 52d44daec2 refactor(local-identity): post-Stage4 cleanups and micro-fixes
- audit: chmod only on file creation, not every append (TOCTOU fix)
- jwt_utils: add extract_unverified_payload() helper
- cli: use extract_unverified_payload + JWTError instead of inline decode
- keys: extract _public_key_bytes() helper, import _b64url from jwt_utils
- security: FileNotFoundError try/except instead of path.exists() (TOCTOU fix)
- serve: cache JWK response at server init instead of per-request recompute

138 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 08:25:21 +01:00

76 lines
2.4 KiB
Python

"""
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 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 .jwt_utils import _b64url_encode as _b64url
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 _public_key_bytes(private_key: RSAPrivateKey) -> tuple[bytes, bytes]:
"""Return (n_bytes, e_bytes) for the public key — shared by key_id and jwk_public."""
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 n_bytes, e_bytes
def key_id(private_key: RSAPrivateKey) -> str:
"""Return a stable 16-hex-char key ID derived from the public key modulus."""
n_bytes, _ = _public_key_bytes(private_key)
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)."""
n_bytes, e_bytes = _public_key_bytes(private_key)
return {
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": hashlib.sha256(n_bytes).hexdigest()[:16],
"n": _b64url(n_bytes),
"e": _b64url(e_bytes),
}