From dee6d88d87f90e062ca4e03fd796cb2defc5e82a Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 19 Aug 2026 23:05:25 +0000 Subject: [PATCH] feat(P04): KMS ECDSA P-256 signing + DER->raw conversion (REQ-337, C-1.1, security-engineer) ---ci--- project: acdl phase: 4 milestone: v1.28 status: execute persona: security-engineer --- --- core/kms_signing.py | 151 +++++++++++++++++++++++++++++++++ docs/kms-provisioning.md | 53 ++++++++++++ tests/test_kms_signing.py | 172 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 core/kms_signing.py create mode 100644 docs/kms-provisioning.md create mode 100644 tests/test_kms_signing.py diff --git a/core/kms_signing.py b/core/kms_signing.py new file mode 100644 index 0000000..81ef300 --- /dev/null +++ b/core/kms_signing.py @@ -0,0 +1,151 @@ +"""KMS-signed JWT issuance for the Nova IdP (REQ-337, REQ-336). + +Signs OIDC tokens with an AWS KMS asymmetric key (``ECC_NIST_P256``, +``ECDSA_SHA_256`` → JWS ``ES256``) and exposes the public key as a JWK +for the JWKS endpoint (REQ-338). + +## DER → raw ECDSA conversion (the #1 gotcha, RESEARCH §5) + +KMS ``sign()`` returns a **DER-encoded** ASN.1 ECDSA signature. JWS +(RFC 7515 §3.1.3) requires the **raw** ``r‖s`` concatenation, each +coordinate 32 bytes big-endian. :func:`der_to_raw_ecdsa` performs the +conversion via ``cryptography``'s ``decode_dss_signature``. This is the +core of REQ-337 and is verified by the CAP-037 round-trip test. + +## Lazy boto3 + +``boto3.client("kms")`` is constructed lazily so the module imports +without AWS creds (mirrors ``nova_idp_auth.py``). Tests inject a mock +client via :func:`set_kms_client_for_testing`. +""" + +from __future__ import annotations + +import base64 +import json +import os +from typing import Any + +import boto3 + +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from cryptography.hazmat.primitives.asymmetric.ec import ( + EllipticCurvePublicKey, +) +from cryptography.hazmat.primitives.serialization import load_der_public_key +from cryptography.hazmat.primitives.asymmetric import ec + + +# Default KMS key alias for Nova OIDC signing (REQ-337). +DEFAULT_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing") + +_kms_client = None + + +def _get_kms_client(): + """Lazy boto3 KMS client singleton (mirrors nova_idp_auth.py).""" + global _kms_client + if _kms_client is None: + _kms_client = boto3.client("kms") + return _kms_client + + +def set_kms_client_for_testing(client: Any) -> None: + """Inject a mock KMS client for tests (no real AWS calls).""" + global _kms_client + _kms_client = client + + +def _b64url(data: bytes) -> str: + """Base64url encode without padding (RFC 7515 §2).""" + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def der_to_raw_ecdsa(der_sig: bytes, coord_len: int = 32) -> bytes: + """Convert a DER-encoded ECDSA signature to raw ``r‖s`` (JWS format). + + KMS returns DER; JWS requires raw ``r‖s`` concatenation, each + coordinate ``coord_len`` bytes big-endian (32 for P-256, 48 for + P-384). Uses ``cryptography``'s ``decode_dss_signature`` to parse + the DER, then zero-pads each integer to ``coord_len``. + + Raises: + ValueError: if a coordinate does not fit in ``coord_len`` bytes + (the integer is larger than the curve allows — indicates a + malformed signature or wrong ``coord_len``). + """ + r, s = decode_dss_signature(der_sig) + if r.bit_length() > coord_len * 8 or s.bit_length() > coord_len * 8: + raise ValueError( + f"ECDSA coordinate does not fit in {coord_len} bytes " + f"(r={r.bit_length()} bits, s={s.bit_length()} bits)" + ) + return r.to_bytes(coord_len, "big") + s.to_bytes(coord_len, "big") + + +def sign_jwt(claims: dict, key_id: str = DEFAULT_KEY_ID) -> str: + """Build + sign a JWT with KMS (REQ-337, REQ-336). + + Args: + claims: the JWT claims payload (``sub, aud, iss, exp, iat, jti, + roles`` per REQ-336, plus ``typ`` for PATs). + key_id: the KMS key ID or alias (default + ``alias/nova-oidc-signing``). + + Returns: + The compact JWS (``header.payload.signature``), ``ES256``, + with the signature in raw ``r‖s`` form (DER→raw converted). + """ + header = {"alg": "ES256", "typ": "JWT", "kid": key_id} + signing_input = ( + _b64url(json.dumps(header, separators=(",", ":"), sort_keys=True).encode()) + + "." + + _b64url(json.dumps(claims, separators=(",", ":"), sort_keys=True).encode()) + ) + resp = _get_kms_client().sign( + KeyId=key_id, + Message=signing_input.encode("ascii"), + MessageType="RAW", + SigningAlgorithm="ECDSA_SHA_256", + ) + der_sig = resp["Signature"] + raw_sig = der_to_raw_ecdsa(der_sig) + return signing_input + "." + _b64url(raw_sig) + + +def get_jwk(key_id: str = DEFAULT_KEY_ID) -> dict: + """Fetch the KMS public key and return it as a JWK (REQ-338). + + Calls ``kms.get_public_key`` → DER SPKI → ``cryptography``'s + ``load_der_public_key`` → JWK ``{"kty":"EC","crv":"P-256","kid":..., + "x":...,"y":...}``. The ``x``/``y`` are base64url-encoded + big-endian 32-byte coordinates. + """ + resp = _get_kms_client().get_public_key(KeyId=key_id) + pub = load_der_public_key(resp["PublicKey"]) + if not isinstance(pub, EllipticCurvePublicKey): + raise ValueError( + f"KMS public key is not an EC key (got {type(pub).__name__})" + ) + nums = pub.public_numbers() + # P-256 coordinates are 32 bytes big-endian. + x = nums.x.to_bytes(32, "big") + y = nums.y.to_bytes(32, "big") + return { + "kty": "EC", + "crv": "P-256", + "kid": key_id, + "x": _b64url(x), + "y": _b64url(y), + "alg": "ES256", + "use": "sig", + } + + +if __name__ == "__main__": # pragma: no cover - CLI inspection helper + import sys + + if "--print-jwk" in sys.argv: + print(json.dumps(get_jwk(), indent=2)) + else: + print("usage: python3 -m core.kms_signing --print-jwks", file=sys.stderr) \ No newline at end of file diff --git a/docs/kms-provisioning.md b/docs/kms-provisioning.md new file mode 100644 index 0000000..3c81909 --- /dev/null +++ b/docs/kms-provisioning.md @@ -0,0 +1,53 @@ +# KMS asymmetric key provisioning (C-1.1) + +This document records the C-1.1 verification for the Nova OIDC signing +KMS key and the provisioning path used by `nova idp setup`. + +## C-1.1 verification (P4) + +C-1.1 requires verifying KMS asymmetric key support **before** +implementation. The verification command is: + +``` +aws kms create-key \ + --key-spec ECC_NIST_P256 \ + --key-usage SIGN_VERIFY \ + --description nova-oidc-signing +``` + +**Result on the P4 build host:** AWS credentials are not available +(`Unable to locate credentials`), so the live verification could not +run. This is recorded as a **P4 CI gate**: the `nova idp setup --check` +command (Wave 8) performs this verification when AWS creds are present +and reports it as a missing prerequisite when they are not. The code +proceeds against the documented KMS API (REQ-337); tests use a test +ECDSA P-256 keypair + mocked `boto3.client("kms")` (no real AWS calls). + +KMS asymmetric signing keys (`ECC_NIST_P256` + `SIGN_VERIFY`) are GA +in all commercial regions (announced 2020-11). The +`ECDSA_SHA_256` signing algorithm is supported. Confidence: high. + +## Key spec (REQ-337) + +* **Key spec:** `ECC_NIST_P256` (NIST P-256 / secp256r1) +* **Key usage:** `SIGN_VERIFY` +* **Signing algorithm:** `ECDSA_SHA_256` (JWS `ES256`) +* **Alias:** `alias/nova-oidc-signing` +* **Rotation:** manual, 90 days (matches D-069 CMK cadence). New key + + re-point alias + JWKS serves both `kid`s during overlap. + +## DER → raw ECDSA conversion (the #1 gotcha) + +KMS `sign()` returns a **DER-encoded** ASN.1 ECDSA signature. JWS +(RFC 7515 §3.1.3) requires the **raw** `r‖s` concatenation, each +coordinate 32 bytes big-endian. The conversion (in +`core/kms_signing.py:der_to_raw_ecdsa`): + +```python +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +r, s = decode_dss_signature(der_sig) +raw = r.to_bytes(32, "big") + s.to_bytes(32, "big") +``` + +This is verified by `tests/test_kms_signing.py` and the CAP-037 +round-trip test (`tests/test_kms_roundtrip.py`). \ No newline at end of file diff --git a/tests/test_kms_signing.py b/tests/test_kms_signing.py new file mode 100644 index 0000000..1d2e215 --- /dev/null +++ b/tests/test_kms_signing.py @@ -0,0 +1,172 @@ +"""KMS signing tests (REQ-337, C-1.1). + +Tests :func:`core.kms_signing.der_to_raw_ecdsa` with a known DER +signature and the full :func:`sign_jwt` round-trip with a mocked KMS +client (no real AWS calls — C-1.1 documented as a CI gate in +``docs/kms-provisioning.md``). +""" + +from __future__ import annotations + +import base64 +import json +import sys +from pathlib import Path +from unittest import mock + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import core.kms_signing as kms_signing +from core.kms_signing import der_to_raw_ecdsa, sign_jwt, get_jwk + +from cryptography.hazmat.primitives.asymmetric import ec, utils +from cryptography.hazmat.primitives import hashes, serialization +import jwt as pyjwt + + +# --------------------------------------------------------------------------- +# Test keypair — generated once per session (P-256). +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def test_keypair(): + priv = ec.generate_private_key(ec.SECP256R1()) + pub = priv.public_key() + return priv, pub + + +@pytest.fixture(scope="module") +def test_pub_der(test_keypair): + _priv, pub = test_keypair + return pub.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + +class _MockKmsSignClient: + """Mock KMS client that signs with a test ECDSA private key (DER).""" + + def __init__(self, priv, pub_der, key_id="alias/nova-oidc-signing"): + self._priv = priv + self._pub_der = pub_der + self._key_id = key_id + + def sign(self, KeyId, Message, MessageType, SigningAlgorithm): + assert SigningAlgorithm == "ECDSA_SHA_256" + assert MessageType == "RAW" + der = self._priv.sign(Message, ec.ECDSA(hashes.SHA256())) + return {"Signature": der, "KeyId": KeyId} + + def get_public_key(self, KeyId): + return {"PublicKey": self._pub_der, "KeyId": KeyId} + + +# --------------------------------------------------------------------------- +# der_to_raw_ecdsa — unit test with a known DER signature. +# --------------------------------------------------------------------------- + + +def test_der_to_raw_ecdsa_known_vector(): + # Minimal DER: SEQUENCE { INTEGER r, INTEGER s }. + # r=5, s=7 → DER: 30 06 02 01 05 02 01 07 + der = b"\x30\x06\x02\x01\x05\x02\x01\x07" + raw = der_to_raw_ecdsa(der) + assert len(raw) == 64 # 32 + 32 + r = int.from_bytes(raw[:32], "big") + s = int.from_bytes(raw[32:], "big") + assert r == 5 + assert s == 7 + + +def test_der_to_raw_ecdsa_real_signature(test_keypair): + priv, _ = test_keypair + msg = b"test message for der->raw" + der = priv.sign(msg, ec.ECDSA(hashes.SHA256())) + raw = der_to_raw_ecdsa(der) + assert len(raw) == 64 + # Round-trip: raw → (r, s) should verify against the message. + r = int.from_bytes(raw[:32], "big") + s = int.from_bytes(raw[32:], "big") + from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature + der2 = encode_dss_signature(r, s) + # Verifying with the re-encoded DER proves the raw split is correct. + priv.public_key().verify(der2, msg, ec.ECDSA(hashes.SHA256())) + + +def test_der_to_raw_ecdsa_rejects_oversized_coord(): + # r needs 33 bytes (2**256+1) → should raise. + from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature + der = encode_dss_signature(2**256 + 1, 1) + with pytest.raises(ValueError): + der_to_raw_ecdsa(der, coord_len=32) + + +# --------------------------------------------------------------------------- +# sign_jwt — full round-trip with mock KMS + pyjwt verification. +# --------------------------------------------------------------------------- + + +def test_sign_jwt_roundtrip_verifies_with_pyjwt(test_keypair, test_pub_der): + priv, pub = test_keypair + client = _MockKmsSignClient(priv, test_pub_der) + kms_signing.set_kms_client_for_testing(client) + try: + claims = { + "sub": "user-1", + "aud": "nova-cli", + "iss": "nova-idp", + "exp": 9999999999, + "iat": 1700000000, + "jti": "test-jti", + "roles": ["developer"], + } + token = sign_jwt(claims, key_id="alias/nova-oidc-signing") + parts = token.split(".") + assert len(parts) == 3 # header.payload.signature + + # Verify the header. + header = json.loads(base64.urlsafe_b64decode(parts[0] + "==")) + assert header["alg"] == "ES256" + assert header["typ"] == "JWT" + assert header["kid"] == "alias/nova-oidc-signing" + + # Verify the signature with pyjwt using the test public key. + pem = pub.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + decoded = pyjwt.decode(token, pem, algorithms=["ES256"], options={"verify_aud": False}) + assert decoded["sub"] == "user-1" + assert decoded["jti"] == "test-jti" + assert decoded["roles"] == ["developer"] + finally: + kms_signing.set_kms_client_for_testing(None) + + +def test_get_jwk_returns_valid_ec_jwk(test_keypair, test_pub_der): + priv, pub = test_keypair + client = _MockKmsSignClient(priv, test_pub_der) + kms_signing.set_kms_client_for_testing(client) + try: + jwk = get_jwk(key_id="alias/nova-oidc-signing") + assert jwk["kty"] == "EC" + assert jwk["crv"] == "P-256" + assert jwk["kid"] == "alias/nova-oidc-signing" + assert jwk["alg"] == "ES256" + # x and y are 32 bytes → 43 base64url chars (no padding). + assert len(jwk["x"]) == 43 + assert len(jwk["y"]) == 43 + # Cross-verify: a JWT signed with the test private key verifies + # with pyjwt using this JWK as the key. + client2 = _MockKmsSignClient(priv, test_pub_der) + kms_signing.set_kms_client_for_testing(client2) + token = sign_jwt({"sub": "x", "exp": 9999999999, "iat": 1, "jti": "j"}) + key = pyjwt.PyJWK(jwk).key + decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False}) + assert decoded["sub"] == "x" + finally: + kms_signing.set_kms_client_for_testing(None) \ No newline at end of file