Skip to content

Commit dbbea91

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.2.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 6ad151f commit dbbea91

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

0 commit comments

Comments
 (0)