diff --git a/CHANGELOG.md b/CHANGELOG.md index b8100b4..55e0a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,10 @@ All notable changes to **openvc** are documented here. The format follows (RFC 9901 §7.1 step 6). A selectively disclosed validity claim is now enforced when the holder presents it, not only when it stays in the issuer-JWT body. +- **A non-string JOSE `kid` is a typed error, not `AttributeError`** + ([#175](https://github.com/luisgf/openvc/issues/175)). `peek_issuer` + and `DidDocument.key_by_kid` now reject it, so `verify_many` isolates + the item instead of aborting the batch. ## [1.24.0] — 2026-07-29 diff --git a/src/openvc/did/base.py b/src/openvc/did/base.py index de243e8..0c84971 100644 --- a/src/openvc/did/base.py +++ b/src/openvc/did/base.py @@ -65,6 +65,8 @@ def key_by_kid(self, kid: str | None) -> VerificationMethod | None: may carry either). If kid is None, fall back to the sole key if unique.""" if kid is None: return self.verification_methods[0] if len(self.verification_methods) == 1 else None + if not isinstance(kid, str): + return None fragment = kid.split("#", 1)[-1] return next( (vm for vm in self.verification_methods if vm.id == kid or vm.kid == fragment), diff --git a/src/openvc/proof/sd_jwt.py b/src/openvc/proof/sd_jwt.py index 1a2b36e..04107c2 100644 --- a/src/openvc/proof/sd_jwt.py +++ b/src/openvc/proof/sd_jwt.py @@ -219,7 +219,10 @@ def peek_issuer(self, sd_jwt: str) -> tuple[str, str | None]: iss = payload.get("iss") if not iss or not isinstance(iss, str): raise MalformedToken("no iss in the issuer-signed JWT") - return iss, header.get("kid") + kid = header.get("kid") + if kid is not None and not isinstance(kid, str): + raise MalformedToken("kid must be a string") + return iss, kid # -- issuance --------------------------------------------------------- # diff --git a/src/openvc/proof/vc_jwt.py b/src/openvc/proof/vc_jwt.py index 6bee994..adb2a12 100644 --- a/src/openvc/proof/vc_jwt.py +++ b/src/openvc/proof/vc_jwt.py @@ -161,7 +161,10 @@ def peek_issuer(self, token: str) -> tuple[str, str | None]: iss = iss.get("id") if not iss or not isinstance(iss, str): raise MalformedToken("no issuer (iss / vc.issuer) present") - return iss, header.get("kid") + kid = header.get("kid") + if kid is not None and not isinstance(kid, str): + raise MalformedToken("kid must be a string") + return iss, kid def peek_claims(self, token: str) -> dict[str, Any]: """Decode the full claim set WITHOUT verifying the signature. UNTRUSTED. diff --git a/tests/test_hostile_input.py b/tests/test_hostile_input.py index 73c6df4..b8ad52d 100644 --- a/tests/test_hostile_input.py +++ b/tests/test_hostile_input.py @@ -101,6 +101,45 @@ def test_vc_jwt_peek_typed_on_non_string_iss_and_non_object_vc(): VcJwtProofSuite().peek_issuer(_jose({"alg": "ES256"}, {"vc": [1, 2]})) +def test_vc_jwt_peek_typed_on_non_string_kid(): + with pytest.raises(OpenvcError): + VcJwtProofSuite().peek_issuer( + _jose({"alg": "ES256", "kid": 1}, {"iss": "did:example:iss"})) + with pytest.raises(OpenvcError): + SdJwtVcProofSuite().peek_issuer( + _jose({"alg": "EdDSA", "typ": "vc+sd-jwt", "kid": 1}, + {"iss": "did:example:iss"}) + "~") + + +def test_verify_many_isolates_a_non_string_kid(): + # #175. kid:1 used to AttributeError in DidDocument.key_by_kid and abort + # the batch. A sibling "not.a.jwt" must still become a typed BatchResult. + from openvc.keys import Ed25519SigningKey + from openvc.multibase import encode_multibase + from openvc.proof._jws import sign_compact + + def _leb128(code: int) -> bytes: + out = bytearray() + while True: + b, code = code & 0x7F, code >> 7 + out.append(b | (0x80 if code else 0)) + if not code: + return bytes(out) + + key = Ed25519SigningKey.generate(kid="k") + raw = base64.urlsafe_b64decode(key.public_jwk()["x"] + "==") + did = "did:key:" + encode_multibase(_leb128(0xED) + raw) + token = sign_compact( + {"alg": key.alg, "typ": "JWT", "kid": 1}, + {"iss": did, "vc": {"type": ["VerifiableCredential"], "issuer": did, + "credentialSubject": {"id": "did:example:alice"}}}, + signing_key=key, + ) + results = verify_many([token, "not.a.jwt"]) + assert len(results) == 2 + assert all((not r.ok) and isinstance(r.error, OpenvcError) for r in results) + + def test_verify_many_isolates_a_non_object_payload(): """The A1 regression: a hostile non-object-payload token must become a fail-closed BatchResult, never abort the sibling that follows it."""