Skip to content

Commit ad5ea22

Browse files
authored
Merge pull request openwallet-foundation#4085 from OpSecId/fix/4077-verify-kid-from-jws-header
fix: prefer JWS header kid over jwk.kid in attach decorator verify (fixes openwallet-foundation#4077)
2 parents 0b192ce + d98154d commit ad5ea22

File tree

2 files changed

+90
-3
lines changed

2 files changed

+90
-3
lines changed

acapy_agent/messaging/decorators/attach_decorator.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,8 +442,15 @@ async def verify(
442442
if not await wallet.verify_message(sign_input, b_sig, verkey, ED25519):
443443
return False
444444

445-
if "kid" in jwk:
446-
encoded_pk = DIDKey.from_did(protected["jwk"]["kid"]).public_key_b58
445+
# Prefer kid from JWS header (canonical per spec); fall back to jwk.kid
446+
kid = None
447+
if getattr(sig, "header", None) and getattr(sig.header, "kid", None):
448+
kid = sig.header.kid
449+
elif "kid" in jwk:
450+
kid = protected["jwk"]["kid"]
451+
452+
if kid:
453+
encoded_pk = DIDKey.from_did(kid).public_key_b58
447454
verkey_to_check.append(encoded_pk)
448455
if not await wallet.verify_message(
449456
sign_input, b_sig, encoded_pk, ED25519

acapy_agent/messaging/decorators/tests/test_attach_decorator.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from copy import deepcopy
33
from datetime import datetime, timezone
4+
from types import SimpleNamespace
45
from unittest import TestCase
56

67
import pytest
@@ -12,7 +13,7 @@
1213
from ....wallet.base import BaseWallet
1314
from ....wallet.did_method import SOV, DIDMethods
1415
from ....wallet.key_type import ED25519
15-
from ....wallet.util import b64_to_bytes, bytes_to_b64
16+
from ....wallet.util import b58_to_bytes, b64_to_bytes, bytes_to_b64, str_to_b64
1617
from ..attach_decorator import (
1718
AttachDecorator,
1819
AttachDecoratorData,
@@ -522,3 +523,82 @@ async def test_indy_sign(self, wallet, seed):
522523
deco_dict["data"]["links"] = "https://en.wikipedia.org/wiki/Potato"
523524
with pytest.raises(BaseModelError):
524525
AttachDecorator.deserialize(deco_dict) # now has base64 and links
526+
527+
@pytest.mark.asyncio
528+
async def test_verify_uses_kid_from_header_when_jwk_has_no_kid(self, wallet, seed):
529+
"""Verify uses kid from JWS header when jwk has no kid (Credo/interop case)."""
530+
# Build a JWS where kid is only in the unprotected header, not in jwk.
531+
# This is the format some agents (e.g. Credo) send; verification must use header.kid.
532+
did_info = await wallet.create_local_did(SOV, ED25519, seed[0])
533+
verkey_b58 = did_info.verkey
534+
kid_full = did_key(verkey_b58)
535+
536+
payload = b"payload for header-kid test"
537+
b64_payload = bytes_to_b64(payload)
538+
# Protected header with jwk but NO kid in jwk
539+
protected = {
540+
"alg": "EdDSA",
541+
"jwk": {
542+
"kty": "OKP",
543+
"crv": "Ed25519",
544+
"x": bytes_to_b64(b58_to_bytes(verkey_b58), urlsafe=True, pad=False),
545+
},
546+
}
547+
b64_protected = str_to_b64(
548+
json.dumps(protected, separators=(",", ":")),
549+
urlsafe=True,
550+
pad=False,
551+
)
552+
sign_input = (b64_protected + "." + b64_payload).encode("ascii")
553+
sig_bytes = await wallet.sign_message(
554+
message=sign_input,
555+
from_verkey=verkey_b58,
556+
)
557+
b64_sig = bytes_to_b64(sig_bytes, urlsafe=True, pad=False)
558+
559+
data = AttachDecoratorData.deserialize(
560+
{
561+
"base64": b64_payload,
562+
"jws": {
563+
"header": {"kid": kid_full},
564+
"protected": b64_protected,
565+
"signature": b64_sig,
566+
},
567+
}
568+
)
569+
assert await data.verify(wallet)
570+
assert await data.verify(wallet, signer_verkey=verkey_b58)
571+
572+
@pytest.mark.asyncio
573+
async def test_verify_uses_kid_from_jwk_when_header_has_no_kid(self, wallet, seed):
574+
"""Verify uses jwk.kid when header has no kid (fallback path)."""
575+
deco = AttachDecorator.data_base64(
576+
mapping=INDY_CRED,
577+
ident=IDENT,
578+
description=DESCRIPTION,
579+
)
580+
did_info = await wallet.create_local_did(SOV, ED25519, seed[0])
581+
await deco.data.sign(did_info.verkey, wallet)
582+
# Force fallback: header has no kid, so verify must use jwk.kid from protected
583+
deco.data.jws_.header = SimpleNamespace(kid=None)
584+
assert await deco.data.verify(wallet)
585+
assert await deco.data.verify(wallet, signer_verkey=did_info.verkey)
586+
587+
@pytest.mark.asyncio
588+
async def test_verify_returns_false_when_signer_verkey_does_not_match(
589+
self, wallet, seed
590+
):
591+
"""Verify returns False when signer_verkey is not the signing key."""
592+
deco = AttachDecorator.data_base64(
593+
mapping=INDY_CRED,
594+
ident=IDENT,
595+
description=DESCRIPTION,
596+
)
597+
did_infos = [await wallet.create_local_did(SOV, ED25519, seed[i]) for i in [0, 1]]
598+
await deco.data.sign(did_infos[0].verkey, wallet)
599+
# Sign with key 0; require key 1 -> should fail
600+
assert not await deco.data.verify(wallet, signer_verkey=did_infos[1].verkey)
601+
# No signer_verkey constraint -> should pass
602+
assert await deco.data.verify(wallet)
603+
# Correct signer_verkey -> should pass
604+
assert await deco.data.verify(wallet, signer_verkey=did_infos[0].verkey)

0 commit comments

Comments
 (0)