Skip to content

Commit 646def2

Browse files
authored
Merge branch 'main' into dependabot/github_actions/all-actions-55dbf210d1
2 parents c3f3842 + 375c90d commit 646def2

8 files changed

Lines changed: 219 additions & 20 deletions

File tree

acapy_agent/wallet/keys/manager.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Multikey class."""
22

33
import logging
4+
from typing import Mapping
45

6+
from aries_askar import Key
57
from pydid import VerificationMethod
68

79
from ...core.profile import ProfileSession
@@ -42,6 +44,13 @@
4244
},
4345
}
4446

47+
# JWK kty/crv pairs supported for Multikey conversion (aligned with JWT algs).
48+
JWK_TO_ALG = {
49+
("OKP", "Ed25519"): "ed25519",
50+
("OKP", "X25519"): "x25519",
51+
("EC", "P-256"): "p256",
52+
}
53+
4554

4655
def multikey_to_verkey(multikey: str):
4756
"""Transform multikey to verkey."""
@@ -60,6 +69,36 @@ def verkey_to_multikey(verkey: str, alg: str):
6069
return multibase.encode(bytes.fromhex(prefixed_key_hex), "base58btc")
6170

6271

72+
def jwk_to_multikey(jwk: Mapping) -> str:
73+
"""Transform a public JWK to multikey.
74+
75+
Supports OKP/Ed25519, OKP/X25519, and EC/P-256 — the curves used by
76+
ACA-Py JWT signing (EdDSA / ES256) and MultikeyManager.
77+
78+
Private key material (``d``) is ignored so this helper always treats the
79+
input as a public key.
80+
"""
81+
if not isinstance(jwk, Mapping):
82+
raise MultikeyManagerError("JWK must be a mapping.")
83+
84+
alg = JWK_TO_ALG.get((jwk.get("kty"), jwk.get("crv")))
85+
if not alg:
86+
raise MultikeyManagerError(
87+
"Unsupported JWK for multikey conversion: "
88+
f"kty={jwk.get('kty')}, crv={jwk.get('crv')}."
89+
)
90+
91+
# Only pass public members to Askar.
92+
public_jwk = {key: value for key, value in jwk.items() if key != "d"}
93+
94+
try:
95+
public_bytes = Key.from_jwk(public_jwk).get_public_bytes()
96+
except Exception as err:
97+
raise MultikeyManagerError(f"Unable to parse JWK: {err}") from err
98+
99+
return verkey_to_multikey(bytes_to_b58(public_bytes), alg=alg)
100+
101+
63102
def key_type_from_multikey(multikey: str) -> KeyType:
64103
"""Derive key_type class from multikey prefix."""
65104
for mapping in ALG_MAPPINGS:
@@ -90,7 +129,14 @@ def multikey_from_verification_method(verification_method: VerificationMethod) -
90129
multikey = verkey_to_multikey(
91130
verification_method.public_key_base58, alg="bls12381g2"
92131
)
93-
# TODO address JsonWebKey based verification methods
132+
133+
elif verification_method.type in ("JsonWebKey2020", "JsonWebKey"):
134+
jwk = verification_method.public_key_jwk
135+
if not jwk:
136+
raise MultikeyManagerError(
137+
f"{verification_method.type} verification method missing publicKeyJwk."
138+
)
139+
multikey = jwk_to_multikey(jwk)
94140

95141
else:
96142
raise MultikeyManagerError("Unknown verification method type.")

acapy_agent/wallet/keys/tests/test_key_operations.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
"""Test MultikeypManager."""
22

3-
from unittest import IsolatedAsyncioTestCase
3+
import json
4+
from unittest import IsolatedAsyncioTestCase, mock
5+
6+
import base58
7+
from aries_askar import Key, KeyAlg
8+
from pydid.verification_method import JsonWebKey2020, VerificationMethod
49

510
from acapy_agent.utils.testing import create_test_profile
611
from acapy_agent.wallet.key_type import KeyTypes
712
from acapy_agent.wallet.keys.manager import (
813
MultikeyManager,
14+
MultikeyManagerError,
15+
jwk_to_multikey,
16+
multikey_from_verification_method,
917
multikey_to_verkey,
1018
verkey_to_multikey,
1119
)
@@ -89,3 +97,69 @@ async def test_key_transformations(self):
8997
]:
9098
assert multikey_to_verkey(multikey) == verkey
9199
assert verkey_to_multikey(verkey, alg=alg) == multikey
100+
101+
async def test_jwk_to_multikey_ed25519_and_p256(self):
102+
for alg, key_alg, expected_multikey in [
103+
(self.ed25519_alg, KeyAlg.ED25519, self.ed25519_multikey),
104+
(self.p256_alg, KeyAlg.P256, self.p256_multikey),
105+
]:
106+
async with self.profile.session() as session:
107+
created = await MultikeyManager(session=session).create(
108+
seed=self.seed, alg=alg
109+
)
110+
111+
askar_key = Key.from_public_bytes(
112+
key_alg, base58.b58decode(multikey_to_verkey(created["multikey"]))
113+
)
114+
jwk = json.loads(askar_key.get_jwk_public())
115+
assert jwk_to_multikey(jwk) == expected_multikey
116+
117+
# Private material must be ignored for public conversion.
118+
jwk_with_d = {**jwk, "d": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}
119+
assert jwk_to_multikey(jwk_with_d) == expected_multikey
120+
121+
async def test_jwk_to_multikey_unsupported(self):
122+
with self.assertRaises(MultikeyManagerError):
123+
jwk_to_multikey({"kty": "EC", "crv": "secp256k1", "x": "x", "y": "y"})
124+
with self.assertRaises(MultikeyManagerError):
125+
jwk_to_multikey("not-a-jwk")
126+
127+
async def test_multikey_from_json_web_key_verification_method(self):
128+
async with self.profile.session() as session:
129+
created = await MultikeyManager(session=session).create(
130+
seed=self.seed, alg=self.ed25519_alg
131+
)
132+
133+
askar_key = Key.from_public_bytes(
134+
KeyAlg.ED25519,
135+
base58.b58decode(multikey_to_verkey(created["multikey"])),
136+
)
137+
jwk = json.loads(askar_key.get_jwk_public())
138+
139+
for vm_type in ("JsonWebKey2020", "JsonWebKey"):
140+
if vm_type == "JsonWebKey2020":
141+
vm = JsonWebKey2020.deserialize(
142+
{
143+
"id": "did:web:example.com#key-01-jwk",
144+
"type": vm_type,
145+
"controller": "did:web:example.com",
146+
"publicKeyJwk": jwk,
147+
}
148+
)
149+
else:
150+
vm = VerificationMethod.deserialize(
151+
{
152+
"id": "did:web:example.com#key-01-jwk",
153+
"type": vm_type,
154+
"controller": "did:web:example.com",
155+
"publicKeyJwk": jwk,
156+
}
157+
)
158+
assert multikey_from_verification_method(vm) == created["multikey"]
159+
160+
async def test_multikey_from_json_web_key_missing_jwk(self):
161+
vm = mock.MagicMock(spec=VerificationMethod)
162+
vm.type = "JsonWebKey2020"
163+
vm.public_key_jwk = None
164+
with self.assertRaises(MultikeyManagerError):
165+
multikey_from_verification_method(vm)

acapy_agent/wallet/tests/test_jwt.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import json
12
from typing import Tuple
23
from unittest import IsolatedAsyncioTestCase
34

5+
import base58
46
import pytest
7+
from aries_askar import Key, KeyAlg
58

69
from acapy_agent.resolver.default.key import KeyDIDResolver
710

@@ -10,6 +13,7 @@
1013
from ...utils.testing import create_test_profile
1114
from ...wallet.did_method import KEY, DIDMethods
1215
from ...wallet.key_type import ED25519, P256, KeyType, KeyTypes
16+
from ...wallet.keys.manager import MultikeyManager, multikey_to_verkey
1317
from ..base import BaseWallet
1418
from ..default_verification_key_strategy import (
1519
BaseVerificationKeyStrategy,
@@ -187,3 +191,57 @@ async def test_verify_x_invalid_signed(self):
187191

188192
with pytest.raises(Exception):
189193
await jwt_verify(self.profile, signed)
194+
195+
async def test_sign_and_verify_with_json_web_key_verification_method(self):
196+
"""JWT verify must accept JsonWebKey2020 publicKeyJwk VMs (e.g. #key-01-jwk)."""
197+
for alg, key_alg in [
198+
("ed25519", KeyAlg.ED25519),
199+
("p256", KeyAlg.P256),
200+
]:
201+
with self.subTest(alg=alg):
202+
vm_id = f"did:web:example.com#key-01-{alg}-jwk"
203+
async with self.profile.session() as session:
204+
created = await MultikeyManager(session=session).create(
205+
seed=self.seed, alg=alg
206+
)
207+
multikey = created["multikey"]
208+
await MultikeyManager(session=session).update(multikey, vm_id)
209+
210+
askar_key = Key.from_public_bytes(
211+
key_alg, base58.b58decode(multikey_to_verkey(multikey))
212+
)
213+
jwk = json.loads(askar_key.get_jwk_public())
214+
did_doc = {
215+
"@context": [
216+
"https://www.w3.org/ns/did/v1",
217+
"https://w3id.org/security/suites/jws-2020/v1",
218+
],
219+
"id": "did:web:example.com",
220+
"verificationMethod": [
221+
{
222+
"id": vm_id,
223+
"type": "JsonWebKey2020",
224+
"controller": "did:web:example.com",
225+
"publicKeyJwk": jwk,
226+
}
227+
],
228+
"assertionMethod": [vm_id],
229+
"authentication": [vm_id],
230+
}
231+
resolver = DIDResolver()
232+
resolver.register_resolver(
233+
MockResolver(
234+
["web"],
235+
resolved=did_doc,
236+
native=True,
237+
)
238+
)
239+
self.profile.context.injector.bind_instance(DIDResolver, resolver)
240+
241+
signed = await jwt_sign(
242+
self.profile, {}, {"hello": "world", "alg": alg}, None, vm_id
243+
)
244+
result = await jwt_verify(self.profile, signed)
245+
assert result.valid
246+
assert result.kid == vm_id
247+
assert result.payload == {"hello": "world", "alg": alg}

docker/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ RUN apt-get update && \
1818
WORKDIR /src
1919

2020
COPY ./pyproject.toml ./poetry.lock ./
21-
# need to install pysqlcipher3 first to ensure build dependencies are available
22-
RUN pip install --no-cache-dir sqlcipher3-wheels==0.5.5 && \
21+
# need to install sqlcipher3-wheels first (binary-only) before poetry install
22+
RUN pip install --no-cache-dir --only-binary=:all: sqlcipher3-wheels==0.5.5 && \
2323
poetry install --no-root
2424

2525
COPY ./acapy_agent ./acapy_agent

poetry.lock

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scenarios/examples/json_ld/example.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,11 @@ async def main():
160160
pause_for_input()
161161

162162
with section("Present example ED25519 credential"):
163-
alice_pres_ex, bob_pres_ex = await jsonld_present_proof_v2(
164-
alice,
163+
bob_pres_ex, alice_pres_ex = await jsonld_present_proof_v2(
165164
bob,
166-
alice_conn.connection_id,
165+
alice,
167166
bob_conn.connection_id,
167+
alice_conn.connection_id,
168168
presentation_definition={
169169
"input_descriptors": [
170170
{
@@ -242,11 +242,11 @@ async def main():
242242
pause_for_input()
243243

244244
with section("Present example P256 credential"):
245-
alice_pres_ex, bob_pres_ex = await jsonld_present_proof_v2(
246-
alice,
245+
bob_pres_ex, alice_pres_ex = await jsonld_present_proof_v2(
247246
bob,
248-
alice_conn.connection_id,
247+
alice,
249248
bob_conn.connection_id,
249+
alice_conn.connection_id,
250250
presentation_definition={
251251
"input_descriptors": [
252252
{
@@ -328,11 +328,11 @@ async def main():
328328
pause_for_input()
329329

330330
with section("Present ED25519 quick context credential"):
331-
alice_pres_ex, bob_pres_ex = await jsonld_present_proof_v2(
332-
alice,
331+
bob_pres_ex, alice_pres_ex = await jsonld_present_proof_v2(
333332
bob,
334-
alice_conn.connection_id,
333+
alice,
335334
bob_conn.connection_id,
335+
alice_conn.connection_id,
336336
presentation_definition={
337337
"input_descriptors": [
338338
{
@@ -410,11 +410,11 @@ async def main():
410410
pause_for_input()
411411

412412
with section("Present BBS+ Credential with SD"):
413-
alice_pres_ex, bob_pres_ex = await jsonld_present_proof_v2(
414-
alice,
413+
bob_pres_ex, alice_pres_ex = await jsonld_present_proof_v2(
415414
bob,
416-
alice_conn.connection_id,
415+
alice,
417416
bob_conn.connection_id,
417+
alice_conn.connection_id,
418418
presentation_definition={
419419
"input_descriptors": [
420420
{

scenarios/poetry.lock

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scenarios/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ docker = "7.2.0"
1313
pytest = "^8.4.2"
1414
pytest-asyncio = "^0.26.0"
1515
pydantic = "^2.13.0"
16+
pytest-rerunfailures = "^16.6"
1617

1718
[tool.pytest.ini_options]
1819
markers = "examples: test the examples"
20+
# Retry only on transient failures talking to the shared public test ledger
21+
# (test.bcovrin.vonx.io), not on genuine scenario/assertion failures.
22+
addopts = '--reruns 1 --reruns-delay 10 --only-rerun "(?i)(pool timeout|ledger request error)"'
1923

2024
[build-system]
2125
requires = ["poetry-core"]

0 commit comments

Comments
 (0)