From 66b0d0d7cb8ce32d03d7f8cad9f7a661009a4d26 Mon Sep 17 00:00:00 2001 From: Timothy Hyde Date: Mon, 9 Mar 2026 18:04:38 -0700 Subject: [PATCH] fix: reject credentials missing required _sd_alg claim Signed-off-by: Timothy Hyde --- src/verifiable_intent/verification/chain.py | 6 +-- tests/test_mode_inference.py | 1 + tests/test_verification_hardening.py | 46 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/verifiable_intent/verification/chain.py b/src/verifiable_intent/verification/chain.py index 9dae606..3c19a03 100644 --- a/src/verifiable_intent/verification/chain.py +++ b/src/verifiable_intent/verification/chain.py @@ -213,7 +213,7 @@ def verify_chain( # 1b. Validate _sd_alg on L1 l1_sd_alg = l1.payload.get("_sd_alg") - if l1_sd_alg is not None and l1_sd_alg != "sha-256": + if l1_sd_alg != "sha-256": result.errors.append(f"L1 _sd_alg must be 'sha-256', got '{l1_sd_alg}'") return result @@ -265,7 +265,7 @@ def verify_chain( # 4a2. Validate _sd_alg on L2 l2_sd_alg = l2.payload.get("_sd_alg") - if l2_sd_alg is not None and l2_sd_alg != "sha-256": + if l2_sd_alg != "sha-256": result.errors.append(f"L2 _sd_alg must be 'sha-256', got '{l2_sd_alg}'") return result @@ -561,7 +561,7 @@ def verify_chain( result.checks_performed.append(f"pair_{pair_idx}_identity_binding") l3_sd_alg = l3.payload.get("_sd_alg") - if l3_sd_alg is not None and l3_sd_alg != "sha-256": + if l3_sd_alg != "sha-256": result.errors.append(f"{l3_label} _sd_alg must be 'sha-256', got '{l3_sd_alg}'") return result diff --git a/tests/test_mode_inference.py b/tests/test_mode_inference.py index cb219d5..ec86268 100644 --- a/tests/test_mode_inference.py +++ b/tests/test_mode_inference.py @@ -254,6 +254,7 @@ def test_mixed_mode_vcts_rejected(): "aud": "https://www.agent.com", "iat": now, "sd_hash": hash_bytes(l1_ser.encode("ascii")), + "_sd_alg": "sha-256", "delegate_payload": [ {"...": hash_disclosure(open_disc)}, {"...": hash_disclosure(final_disc)}, diff --git a/tests/test_verification_hardening.py b/tests/test_verification_hardening.py index 61a91c1..980b20d 100644 --- a/tests/test_verification_hardening.py +++ b/tests/test_verification_hardening.py @@ -444,6 +444,45 @@ def test_wrong_sd_alg_l1_rejected(self): assert not result.valid assert any("_sd_alg" in e and "sha-512" in e for e in result.errors) + def test_missing_sd_alg_l1_rejected(self): + """L1 with _sd_alg absent must be rejected (claim is REQUIRED per spec).""" + issuer = get_issuer_keys() + user = get_user_keys() + now = int(time.time()) + l1 = _make_l1(issuer, user, now) + + payload_without = {k: v for k, v in l1.payload.items() if k != "_sd_alg"} + l1_no_alg = SdJwt( + header=l1.header, + payload=payload_without, + signature=l1.signature, + disclosures=l1.disclosures, + disclosure_values=l1.disclosure_values, + ) + + l2 = _make_immediate_l2(user, l1, now) + + result = verify_chain(l1_no_alg, l2, skip_issuer_verification=True) + assert not result.valid + assert any("_sd_alg" in e for e in result.errors) + + def test_missing_sd_alg_l2_rejected(self): + """L2 with _sd_alg absent must be rejected (claim is REQUIRED per spec).""" + from verifiable_intent.crypto.sd_jwt import create_sd_jwt + + issuer = get_issuer_keys() + user = get_user_keys() + now = int(time.time()) + l1 = _make_l1(issuer, user, now) + l2 = _make_immediate_l2(user, l1, now) + + payload_without = {k: v for k, v in l2.payload.items() if k != "_sd_alg"} + l2_no_alg = create_sd_jwt(l2.header, payload_without, l2.disclosures, user.private_key) + + result = verify_chain(l1, l2_no_alg, issuer_public_key=issuer.public_key) + assert not result.valid + assert any("_sd_alg" in e for e in result.errors) + # --- Fix #5: Model-level mode enforcement --- @@ -1087,6 +1126,7 @@ def test_immediate_empty_delegates_rejected(self): "aud": "https://www.agent.com", "iat": now, "sd_hash": hash_bytes(l1.serialize().encode("ascii")), + "_sd_alg": "sha-256", "mode": "immediate", "delegate_payload": [], } @@ -1115,6 +1155,7 @@ def test_autonomous_empty_delegates_rejected(self): "aud": "https://www.agent.com", "iat": now, "sd_hash": hash_bytes(l1.serialize().encode("ascii")), + "_sd_alg": "sha-256", "delegate_payload": [], } # No open or final VCTs → mode inferred as immediate → expect kb-sd-jwt typ @@ -1144,6 +1185,7 @@ def test_l1_cnf_jwk_missing_x(self): "iat": now, "exp": now + 86400, "vct": "https://credentials.mastercard.com/card", + "_sd_alg": "sha-256", "pan_last_four": "1234", "scheme": "Mastercard", "cnf": {"jwk": {"kty": "EC", "crv": "P-256", "y": user.public_jwk["y"]}}, @@ -1157,6 +1199,7 @@ def test_l1_cnf_jwk_missing_x(self): "aud": "test", "iat": now, "sd_hash": hash_bytes(l1.serialize().encode("ascii")), + "_sd_alg": "sha-256", "delegate_payload": [], } l2_header = {"alg": "ES256", "typ": "kb-sd-jwt"} @@ -1183,6 +1226,7 @@ def test_l1_cnf_jwk_bad_base64(self): "pan_last_four": "1234", "scheme": "Mastercard", "cnf": {"jwk": {"kty": "EC", "crv": "P-256", "x": "!!!invalid!!!", "y": "!!!bad!!!"}}, + "_sd_alg": "sha-256", } l1_header = {"alg": "ES256", "typ": "sd+jwt"} l1 = create_sd_jwt(l1_header, l1_payload, [], issuer.private_key) @@ -1192,6 +1236,7 @@ def test_l1_cnf_jwk_bad_base64(self): "aud": "test", "iat": now, "sd_hash": hash_bytes(l1.serialize().encode("ascii")), + "_sd_alg": "sha-256", "delegate_payload": [], } l2_header = {"alg": "ES256", "typ": "kb-sd-jwt"} @@ -1229,6 +1274,7 @@ def test_unrecognized_vct_tracked(self): "aud": "test", "iat": now, "sd_hash": hash_bytes(l1_ser.encode("ascii")), + "_sd_alg": "sha-256", "delegate_payload": [{"...": disc_hash}], } l2_header = {"alg": "ES256", "typ": "kb-sd-jwt"}