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:
@@ -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)
|
||||||
@@ -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`).
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user