Skip to content

Commit 0cbb5db

Browse files
PatStLouisCopilot
andauthored
Upgrade askar-anoncreds endpoints and unify tenant-ui (WebVH + Indy) (#1804)
* Upgrade askar-anoncreds endpoints and unify tenant-ui (WebVH + Indy) --------- Signed-off-by: Patrick St-Louis <patrick.st-louis@opsecid.ca> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a047108 commit 0cbb5db

36 files changed

+1538
-321
lines changed

plugins/traction_innkeeper/traction_innkeeper/v1_0/connections/routes.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
from acapy_agent.storage.error import StorageNotFoundError
88
from aiohttp import web
99
from aiohttp_apispec import docs, match_info_schema, response_schema
10-
from connections.v1_0.routes import (ConnectionsConnIdMatchInfoSchema,
11-
InvitationResultSchema)
10+
from connections.v1_0.routes import (
11+
ConnectionsConnIdMatchInfoSchema,
12+
InvitationResultSchema,
13+
)
1214
from marshmallow import fields
1315

1416
LOGGER = logging.getLogger(__name__)
1517

18+
1619
class InvitationResponseSchema(InvitationResultSchema):
1720
"""Response schema for a previous connection invitation."""
1821

@@ -22,6 +25,7 @@ class InvitationResponseSchema(InvitationResultSchema):
2225
metadata={"description": "Optional alias for the connection", "example": "Bob"},
2326
)
2427

28+
2529
@docs(tags=["connection"], summary="Fetch connection invitation")
2630
@match_info_schema(ConnectionsConnIdMatchInfoSchema())
2731
@response_schema(InvitationResponseSchema(), 200, description="")
@@ -71,13 +75,13 @@ async def connections_invitation(request: web.BaseRequest):
7175
except BaseModelError as err:
7276
raise web.HTTPBadRequest(reason=err.roll_up) from err
7377

74-
invitation_url = invitation.to_url(base_url) # Let's see what to_url produces
78+
invitation_url = invitation.to_url(base_url) # Let's see what to_url produces
7579

7680
# Always prepend base_url if to_url returns only query params
77-
if invitation_url.startswith("?"): # Check if to_url returned only query params
81+
if invitation_url.startswith("?"): # Check if to_url returned only query params
7882
constructed_invitation_url = f"{base_url}{invitation_url}"
7983
else:
80-
constructed_invitation_url = invitation_url # Assume to_url worked correctly
84+
constructed_invitation_url = invitation_url # Assume to_url worked correctly
8185

8286
result = {
8387
"connection_id": connection_id,
@@ -106,4 +110,4 @@ async def register(app: web.Application):
106110

107111

108112
def post_process_routes(app: web.Application):
109-
pass
113+
pass

plugins/traction_innkeeper/traction_innkeeper/v1_0/creddef_storage/creddef_storage_service.py

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import re
23
from typing import Optional
34

45
from acapy_agent.core.event_bus import EventBus, Event
@@ -8,7 +9,10 @@
89
from .models import CredDefStorageRecord
910

1011
from acapy_agent.messaging.credential_definitions.util import (
11-
EVENT_LISTENER_PATTERN as CREDDEF_EVENT_LISTENER_PATTERN,
12+
EVENT_LISTENER_PATTERN as INDY_CREDDEF_EVENT_PATTERN,
13+
)
14+
from acapy_agent.anoncreds.events import (
15+
CRED_DEF_FINISHED_EVENT as ANONCREDS_CREDDEF_FINISHED_EVENT,
1216
)
1317

1418
LOGGER = logging.getLogger(__name__)
@@ -61,20 +65,85 @@ async def list_items(
6165
self.logger.info(f"< list_items({tag_filter}, {post_filter}): {len(records)}")
6266
return records
6367

68+
def _is_anoncreds_wallet(self, profile: Profile) -> bool:
69+
"""Check if the wallet is an anoncreds wallet."""
70+
wallet_type = profile.settings.get("wallet.type", "askar")
71+
return wallet_type in ("askar-anoncreds", "kanon-anoncreds")
72+
73+
async def _fetch_tag(
74+
self,
75+
profile: Profile,
76+
cred_def_id: str,
77+
) -> Optional[str]:
78+
"""Fetch tag from registry for AnonCreds credential definitions.
79+
80+
AnonCreds events never include tag in the event payload, so we must fetch it from the registry.
81+
This function is only called for AnonCreds credential definitions.
82+
83+
Returns tag from registry, or None if fetch fails.
84+
"""
85+
from acapy_agent.anoncreds.registry import AnonCredsRegistry
86+
from acapy_agent.anoncreds.base import AnonCredsResolutionError
87+
88+
try:
89+
anoncreds_registry = profile.inject(AnonCredsRegistry)
90+
cred_def_result = await anoncreds_registry.get_credential_definition(
91+
profile, cred_def_id
92+
)
93+
94+
cred_def = cred_def_result.credential_definition
95+
tag = cred_def.tag or "default"
96+
97+
self.logger.debug(f"Fetched tag from registry: {tag}")
98+
return tag
99+
except AnonCredsResolutionError:
100+
# Registry doesn't support this identifier
101+
self.logger.error(
102+
f"Registry could not resolve credential definition: {cred_def_id}"
103+
)
104+
return None
105+
except Exception as err:
106+
# Other errors from registry (e.g., network issues, injection errors)
107+
self.logger.error(
108+
f"Error fetching credential definition from registry: {err}"
109+
)
110+
return None
111+
112+
async def _create_storage_record(
113+
self, profile: Profile, data: dict
114+
) -> CredDefStorageRecord:
115+
"""Create and save a credential definition storage record."""
116+
rec: CredDefStorageRecord = CredDefStorageRecord.deserialize(data)
117+
self.logger.debug(f"cred_def_storage_rec = {rec}")
118+
async with profile.session() as session:
119+
await rec.save(session, reason="New cred def storage record")
120+
return rec
121+
64122
async def add_item(self, profile: Profile, data: dict):
65123
self.logger.info(f"> add_item({data})")
66-
# check if
67-
cred_def_id = data["cred_def_id"]
124+
cred_def_id = data.get("cred_def_id")
125+
if not cred_def_id:
126+
raise ValueError("cred_def_id is required in data")
127+
128+
# Early return if record already exists
68129
rec = await self.read_item(profile, cred_def_id)
69-
if not rec:
70-
try:
71-
rec: CredDefStorageRecord = CredDefStorageRecord.deserialize(data)
72-
self.logger.debug(f"cred_def_storage_rec = {rec}")
73-
async with profile.session() as session:
74-
await rec.save(session, reason="New cred def storage record")
75-
except Exception as err:
76-
self.logger.error("Error adding cred def storage record.", err)
77-
raise err
130+
if rec:
131+
self.logger.info(f"< add_item({cred_def_id}): {rec}")
132+
return rec
133+
134+
# Fetch tag from registry for AnonCreds credential definitions
135+
# AnonCreds events never include tag, so we must fetch it
136+
if self._is_anoncreds_wallet(profile):
137+
tag = await self._fetch_tag(profile, cred_def_id)
138+
if tag:
139+
data["tag"] = tag
140+
141+
# Create and save the storage record
142+
try:
143+
rec = await self._create_storage_record(profile, data)
144+
except Exception as err:
145+
self.logger.error("Error adding cred def storage record.", err)
146+
raise err
78147

79148
self.logger.info(f"< add_item({cred_def_id}): {rec}")
80149
return rec
@@ -105,15 +174,66 @@ async def remove_item(self, profile: Profile, cred_def_id: str):
105174

106175

107176
def subscribe(bus: EventBus):
108-
bus.subscribe(CREDDEF_EVENT_LISTENER_PATTERN, creddef_event_handler)
177+
# Subscribe to both Indy and AnonCreds credential definition events
178+
bus.subscribe(INDY_CREDDEF_EVENT_PATTERN, creddef_event_handler)
179+
# Explicitly compile as literal pattern to ensure it's a Pattern object, not a string
180+
bus.subscribe(
181+
re.compile(re.escape(ANONCREDS_CREDDEF_FINISHED_EVENT)), creddef_event_handler
182+
)
183+
184+
185+
def _normalize_creddef_event_payload(event: Event) -> dict:
186+
"""Normalize credential definition event payload from either Indy (dict with context) or AnonCreds (NamedTuple) format.
187+
188+
AnonCreds events use CredDefFinishedPayload NamedTuple (not a dict), so we check for
189+
NamedTuple attributes and convert to a dict format for unified processing.
190+
"""
191+
payload = event.payload
192+
193+
# Check if it's an AnonCreds event (NamedTuple)
194+
# AnonCreds events are CredDefFinishedPayload NamedTuples, not dicts
195+
if hasattr(payload, "schema_id") and hasattr(payload, "cred_def_id"):
196+
# AnonCreds event: CredDefFinishedPayload NamedTuple
197+
rev_reg_size = payload.max_cred_num if payload.support_revocation else None
198+
return {
199+
"cred_def_id": payload.cred_def_id,
200+
"schema_id": payload.schema_id,
201+
"tag": None, # NEVER in AnonCreds event, must be fetched from registry
202+
"support_revocation": payload.support_revocation,
203+
"rev_reg_size": rev_reg_size, # Always set (from max_cred_num)
204+
"issuer_id": payload.issuer_id,
205+
"options": payload.options, # Preserve for any other fields
206+
}
207+
elif isinstance(payload, dict) and "context" in payload:
208+
# Indy event: dict with "context" key
209+
context = payload["context"]
210+
return {
211+
"cred_def_id": context.get("cred_def_id"),
212+
"schema_id": context.get("schema_id"),
213+
"tag": context.get("tag"),
214+
"support_revocation": context.get("support_revocation", False),
215+
"rev_reg_size": context.get("rev_reg_size"),
216+
"issuer_did": context.get("issuer_did"),
217+
"options": context.get("options", {}),
218+
}
219+
else:
220+
# Fallback: assume it's already in the right format
221+
return payload if isinstance(payload, dict) else {}
109222

110223

111224
async def creddef_event_handler(profile: Profile, event: Event):
112225
LOGGER.info("> creddef_event_handler")
113226
LOGGER.debug(f"profile = {profile}")
114227
LOGGER.debug(f"event = {event}")
228+
LOGGER.debug(f"event.payload = {event.payload}")
229+
115230
srv = profile.inject(CredDefStorageService)
116-
storage_record = await srv.add_item(profile, event.payload["context"])
231+
232+
# Normalize event payload to common format
233+
normalized_data = _normalize_creddef_event_payload(event)
234+
LOGGER.debug(f"normalized_data = {normalized_data}")
235+
236+
storage_record = await srv.add_item(profile, normalized_data)
117237
LOGGER.debug(f"creddef_storage_record = {storage_record}")
118238

119239
LOGGER.info("< creddef_event_handler")

plugins/traction_innkeeper/traction_innkeeper/v1_0/creddef_storage/models.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from typing import Optional
23

34
from acapy_agent.messaging.models.base_record import BaseRecord, BaseRecordSchema
@@ -9,7 +10,7 @@
910
INDY_CRED_DEF_ID_VALIDATE,
1011
INDY_CRED_DEF_ID_EXAMPLE,
1112
)
12-
from marshmallow import EXCLUDE, fields
13+
from marshmallow import EXCLUDE, fields, ValidationError
1314

1415

1516
class CredDefStorageRecord(BaseRecord):
@@ -56,6 +57,63 @@ def record_value(self) -> dict:
5657
}
5758

5859

60+
def validate_cred_def_id(value):
61+
"""Validate credential definition ID as either Indy or AnonCreds format."""
62+
if not value or not isinstance(value, str):
63+
raise ValidationError("Credential definition ID must be a non-empty string")
64+
65+
if len(value.strip()) == 0:
66+
raise ValidationError("Credential definition ID cannot be empty")
67+
68+
# Indy cred def ID format: DID:3:CL:(seq_no|schema_id):tag
69+
# Pattern: ^[base58]{21,22}:3:CL:(([1-9][0-9]*)|([base58]{21,22}:2:.+:[0-9.]+)):(.+)?$
70+
# Schema reference can be either a sequence number or full schema ID
71+
indy_pattern = (
72+
r"^[1-9A-HJ-NP-Za-km-z]{21,22}" # issuer DID
73+
r":3" # cred def id marker
74+
r":CL" # sig alg
75+
r":(([1-9][0-9]*)|([1-9A-HJ-NP-Za-km-z]{21,22}:2:.+:[0-9.]+))" # schema txn/id
76+
r":(.+)?$" # optional tag
77+
)
78+
79+
# Check if it matches Indy format
80+
if re.match(indy_pattern, value):
81+
# Validate using the official Indy validator
82+
try:
83+
INDY_CRED_DEF_ID_VALIDATE(value)
84+
except ValidationError:
85+
# If Indy validator fails, still accept it (might be edge case)
86+
pass
87+
88+
# Accept all non-empty strings as valid credential definition IDs
89+
# (Indy format validated above, everything else is AnonCreds)
90+
return
91+
92+
93+
def validate_schema_id_for_creddef(value):
94+
"""Validate schema ID as either Indy or AnonCreds format."""
95+
if not value or not isinstance(value, str):
96+
raise ValidationError("Schema ID must be a non-empty string")
97+
98+
if len(value.strip()) == 0:
99+
raise ValidationError("Schema ID cannot be empty")
100+
101+
# Indy schema ID format: DID:2:name:version
102+
indy_pattern = r"^[1-9A-HJ-NP-Za-km-z]{21,22}:2:.+:[0-9.]+$"
103+
104+
# Check if it matches Indy format
105+
if re.match(indy_pattern, value):
106+
# Validate using the official Indy validator
107+
try:
108+
INDY_SCHEMA_ID_VALIDATE(value)
109+
except ValidationError:
110+
# If Indy validator fails, still accept it (might be edge case)
111+
pass
112+
113+
# Accept all non-empty strings as valid schema IDs
114+
return
115+
116+
59117
class CredDefStorageRecordSchema(BaseRecordSchema):
60118
"""Traction CredDef Storage Record Schema."""
61119

@@ -67,16 +125,16 @@ class Meta:
67125

68126
cred_def_id = fields.Str(
69127
required=True,
70-
validate=INDY_CRED_DEF_ID_VALIDATE,
128+
validate=validate_cred_def_id,
71129
metadata={
72-
"description": "Cred Def identifier",
130+
"description": "Cred Def identifier (Indy or AnonCreds format)",
73131
"example": INDY_CRED_DEF_ID_EXAMPLE,
74132
},
75133
)
76134
schema_id = fields.Str(
77-
validate=INDY_SCHEMA_ID_VALIDATE,
135+
validate=validate_schema_id_for_creddef,
78136
metadata={
79-
"description": "Schema identifier",
137+
"description": "Schema identifier (Indy or AnonCreds format)",
80138
"example": INDY_SCHEMA_ID_EXAMPLE,
81139
},
82140
)

plugins/traction_innkeeper/traction_innkeeper/v1_0/creddef_storage/routes.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33

44
from aiohttp import web
5-
from aiohttp_apispec import docs, response_schema, match_info_schema
5+
from aiohttp_apispec import docs, response_schema, match_info_schema, request_schema
66
from acapy_agent.admin.request_context import AdminRequestContext
77

88
from acapy_agent.messaging.models.base import BaseModelError
@@ -58,6 +58,16 @@ class CredDefIdMatchInfoSchema(OpenAPISchema):
5858
)
5959

6060

61+
class CredDefStorageAddSchema(OpenAPISchema):
62+
cred_def_id = fields.Str(
63+
metadata={"description": "Credential Definition identifier"}, required=True
64+
)
65+
schema_id = fields.Str(
66+
metadata={"description": "Schema identifier (Indy or AnonCreds format)"},
67+
required=True,
68+
)
69+
70+
6171
class CredDefStorageOperationResponseSchema(OpenAPISchema):
6272
"""Response schema for simple operations."""
6373

@@ -88,6 +98,24 @@ async def creddef_storage_list(request: web.BaseRequest):
8898
return web.json_response({"results": results})
8999

90100

101+
@docs(
102+
tags=[SWAGGER_CATEGORY],
103+
)
104+
@request_schema(CredDefStorageAddSchema())
105+
@response_schema(CredDefStorageRecordSchema(), 200, description="")
106+
@error_handler
107+
@tenant_authentication
108+
async def creddef_storage_add(request: web.BaseRequest):
109+
context: AdminRequestContext = request["context"]
110+
profile = context.profile
111+
storage_srv = context.inject_or(CredDefStorageService)
112+
body = await request.json()
113+
114+
record = await storage_srv.add_item(profile, body)
115+
116+
return web.json_response(record.serialize())
117+
118+
91119
@docs(
92120
tags=[SWAGGER_CATEGORY],
93121
)
@@ -134,6 +162,7 @@ async def register(app: web.Application):
134162
creddef_storage_list,
135163
allow_head=False,
136164
),
165+
web.post("/credential-definition-storage", creddef_storage_add),
137166
web.get(
138167
"/credential-definition-storage/{cred_def_id}",
139168
creddef_storage_get,

0 commit comments

Comments
 (0)