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
---
This commit is contained in:
Jon Chery
2026-08-19 23:05:25 +00:00
parent fe0ee6aa45
commit dee6d88d87
3 changed files with 376 additions and 0 deletions
+151
View File
@@ -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)
+53
View File
@@ -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`).
+172
View File
@@ -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)