Skip to content

Commit a6dc040

Browse files
committed
fix: prefer JWS header kid over jwk.kid in attach decorator verify (fixes openwallet-foundation#4077)
When verifying JWS in attach decorator data, use kid from the JWS unprotected header first (canonical per spec). Fall back to jwk.kid only when header has no kid. Fixes DIDComm connection failure with agents (e.g. Credo) that put kid only in header and not in jwk. Backport of openwallet-foundation#4085 for 1.3.lts. - Add test_verify_uses_kid_from_header_when_jwk_has_no_kid - Add test_verify_uses_kid_from_jwk_when_header_has_no_kid - Add test_verify_returns_false_when_signer_verkey_does_not_match Signed-off-by: Patrick St-Louis <patrick.st-louis@opsecid.ca> Made-with: Cursor
1 parent 32a4af3 commit a6dc040

File tree

2 files changed

+92
-3
lines changed

2 files changed

+92
-3
lines changed

acapy_agent/messaging/decorators/attach_decorator.py

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

457-
if "kid" in jwk:
458-
encoded_pk = DIDKey.from_did(protected["jwk"]["kid"]).public_key_b58
457+
# Prefer kid from JWS header (canonical per spec); fall back to jwk.kid
458+
kid = None
459+
if getattr(sig, "header", None) and getattr(sig.header, "kid", None):
460+
kid = sig.header.kid
461+
elif "kid" in jwk:
462+
kid = protected["jwk"]["kid"]
463+
464+
if kid:
465+
encoded_pk = DIDKey.from_did(kid).public_key_b58
459466
verkey_to_check.append(encoded_pk)
460467
if not await wallet.verify_message(
461468
sign_input, b_sig, encoded_pk, ED25519

acapy_agent/messaging/decorators/tests/test_attach_decorator.py

Lines changed: 83 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,84 @@ 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 = [
598+
await wallet.create_local_did(SOV, ED25519, seed[i]) for i in [0, 1]
599+
]
600+
await deco.data.sign(did_infos[0].verkey, wallet)
601+
# Sign with key 0; require key 1 -> should fail
602+
assert not await deco.data.verify(wallet, signer_verkey=did_infos[1].verkey)
603+
# No signer_verkey constraint -> should pass
604+
assert await deco.data.verify(wallet)
605+
# Correct signer_verkey -> should pass
606+
assert await deco.data.verify(wallet, signer_verkey=did_infos[0].verkey)

0 commit comments

Comments
 (0)