Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/openvc/did/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion src/openvc/proof/sd_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------------------------------- #

Expand Down
5 changes: 4 additions & 1 deletion src/openvc/proof/vc_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions tests/test_hostile_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading