diff --git a/oid4vc/mso_mdoc/__init__.py b/oid4vc/mso_mdoc/__init__.py index 6925d1f6b..740d8ba92 100644 --- a/oid4vc/mso_mdoc/__init__.py +++ b/oid4vc/mso_mdoc/__init__.py @@ -1,8 +1,7 @@ """MSO_MDOC Credential Handler Plugin.""" import logging -import os -from typing import Optional, Union +from typing import Optional from acapy_agent.config.injection_context import InjectionContext from acapy_agent.core.event_bus import EventBus @@ -11,91 +10,25 @@ from mso_mdoc.cred_processor import MsoMdocCredProcessor from mso_mdoc.key_generation import generate_default_keys_and_certs -from mso_mdoc.mdoc.verifier import FileTrustStore, WalletTrustStore from mso_mdoc.storage import MdocStorageManager from oid4vc.cred_processor import CredProcessors from . import routes as routes LOGGER = logging.getLogger(__name__) -# Trust store type configuration -TRUST_STORE_TYPE_FILE = "file" -TRUST_STORE_TYPE_WALLET = "wallet" - # Store reference to processor for startup initialization _mso_mdoc_processor: Optional[MsoMdocCredProcessor] = None -def create_trust_store( - profile: Optional[Profile] = None, -) -> Optional[Union[FileTrustStore, WalletTrustStore]]: - """Create a trust store based on configuration. - - Environment variables: - - OID4VC_MDOC_TRUST_STORE_TYPE: "file" or "wallet" (default: "file") - - OID4VC_MDOC_TRUST_ANCHORS_PATH: Path for file-based trust store - - Args: - profile: ACA-Py profile for wallet-based trust store - (optional, required for wallet type) - - Returns: - Configured trust store instance or None if disabled - """ - trust_store_type = os.getenv( - "OID4VC_MDOC_TRUST_STORE_TYPE", TRUST_STORE_TYPE_FILE - ).lower() - - if trust_store_type == TRUST_STORE_TYPE_WALLET: - if profile is None: - LOGGER.warning( - "Wallet trust store requires a profile, deferring initialization" - ) - return None - LOGGER.info("Using wallet-based trust store") - return WalletTrustStore(profile) - elif trust_store_type == TRUST_STORE_TYPE_FILE: - trust_store_path = os.getenv( - "OID4VC_MDOC_TRUST_ANCHORS_PATH", "/etc/acapy/mdoc/trust-anchors/" - ) - LOGGER.info("Using file-based trust store at: %s", trust_store_path) - return FileTrustStore(trust_store_path) - elif trust_store_type == "none" or trust_store_type == "disabled": - LOGGER.info("Trust store disabled") - return None - else: - LOGGER.warning( - "Unknown trust store type '%s', falling back to file-based", - trust_store_type, - ) - trust_store_path = os.getenv( - "OID4VC_MDOC_TRUST_ANCHORS_PATH", "/etc/acapy/mdoc/trust-anchors/" - ) - return FileTrustStore(trust_store_path) - - async def on_startup(profile: Profile, event: object): - """Handle startup event to initialize profile-dependent resources.""" - global _mso_mdoc_processor + """Handle startup event to initialize profile-dependent resources. + Trust anchors are always wallet-scoped; a fresh WalletTrustStore is + constructed per-request in verify_credential / verify_presentation so + each tenant's Askar partition is queried automatically. + """ LOGGER.info("MSO_MDOC plugin startup - initializing profile-dependent resources") - trust_store_type = os.getenv( - "OID4VC_MDOC_TRUST_STORE_TYPE", TRUST_STORE_TYPE_FILE - ).lower() - - # If using wallet trust store, initialize it now that we have a profile - if trust_store_type == TRUST_STORE_TYPE_WALLET and _mso_mdoc_processor is not None: - trust_store = WalletTrustStore(profile) - try: - await trust_store.refresh_cache() - LOGGER.info("Loaded trust anchors from wallet") - except Exception as e: - LOGGER.warning("Failed to load trust anchors from wallet: %s", e) - - # Update the processor with the trust store - _mso_mdoc_processor.trust_store = trust_store - # Initialize storage and generate default keys/certs if needed try: storage_manager = MdocStorageManager(profile) @@ -127,28 +60,17 @@ async def setup(context: InjectionContext): LOGGER.info("Setting up MSO_MDOC plugin") - # For wallet trust store, we'll initialize the trust store in on_startup - # For file-based trust store, we can initialize now - trust_store_type = os.getenv( - "OID4VC_MDOC_TRUST_STORE_TYPE", TRUST_STORE_TYPE_FILE - ).lower() - - if trust_store_type == TRUST_STORE_TYPE_WALLET: - # Defer trust store initialization until startup - trust_store = None - LOGGER.info("Wallet-based trust store will be initialized at startup") - else: - # File-based trust store can be initialized immediately - trust_store = create_trust_store() - + # Trust anchors are always wallet-scoped. A fresh WalletTrustStore is + # constructed per-request inside verify_credential / verify_presentation + # so each tenant's Askar partition is used automatically. # Register credential processor processors = context.inject(CredProcessors) - _mso_mdoc_processor = MsoMdocCredProcessor(trust_store=trust_store) + _mso_mdoc_processor = MsoMdocCredProcessor() processors.register_issuer("mso_mdoc", _mso_mdoc_processor) processors.register_cred_verifier("mso_mdoc", _mso_mdoc_processor) processors.register_pres_verifier("mso_mdoc", _mso_mdoc_processor) - # Register startup event handler for profile-dependent initialization + # Register startup event handler for storage initialization event_bus = context.inject(EventBus) event_bus.subscribe(STARTUP_EVENT_PATTERN, on_startup) LOGGER.info("MSO_MDOC plugin registered startup handler") diff --git a/oid4vc/mso_mdoc/cred_processor.py b/oid4vc/mso_mdoc/cred_processor.py index 24859ea36..977302a68 100644 --- a/oid4vc/mso_mdoc/cred_processor.py +++ b/oid4vc/mso_mdoc/cred_processor.py @@ -746,15 +746,9 @@ async def verify_credential( credential: Any, ): """Verify an mso_mdoc credential.""" - # In wallet trust-store mode, self.trust_store was built at startup - # with the root profile. Sub-wallet credential verification must use - # the calling profile so per-tenant Askar partitions are queried. - # For file- or None-based trust stores the singleton is fine. - if os.getenv("OID4VC_MDOC_TRUST_STORE_TYPE", "file").lower() == "wallet": - trust_store = WalletTrustStore(profile) - else: - trust_store = self.trust_store - + # Always build a per-request WalletTrustStore from the calling profile + # so each tenant's Askar partition is queried (wallet-scoped registry). + trust_store = WalletTrustStore(profile) verifier = MsoMdocCredVerifier(trust_store=trust_store) return await verifier.verify_credential(profile, credential) @@ -765,17 +759,9 @@ async def verify_presentation( presentation_record: "OID4VPPresentation", ): """Verify an mso_mdoc presentation.""" - # In wallet trust-store mode, self.trust_store was built at startup - # with the root profile. Sub-wallet VP verification must use the - # calling profile so per-tenant Askar partitions are queried and - # anchors registered via /mso_mdoc/trust-anchors with a sub-wallet - # Bearer token are visible. For file- or None-based trust stores - # the singleton is reused as-is. - if os.getenv("OID4VC_MDOC_TRUST_STORE_TYPE", "file").lower() == "wallet": - trust_store = WalletTrustStore(profile) - else: - trust_store = self.trust_store - + # Always build a per-request WalletTrustStore from the calling profile + # so each tenant's Askar partition is queried (wallet-scoped registry). + trust_store = WalletTrustStore(profile) verifier = MsoMdocPresVerifier(trust_store=trust_store) return await verifier.verify_presentation( profile, presentation, presentation_record diff --git a/oid4vc/mso_mdoc/mdoc/verifier.py b/oid4vc/mso_mdoc/mdoc/verifier.py index 74859f380..a957b6e3a 100644 --- a/oid4vc/mso_mdoc/mdoc/verifier.py +++ b/oid4vc/mso_mdoc/mdoc/verifier.py @@ -3,7 +3,6 @@ import base64 import json import logging -import os from abc import abstractmethod from dataclasses import dataclass from typing import Any, List, Optional, Protocol @@ -87,30 +86,6 @@ def get_trust_anchors(self) -> List[str]: ... -class FileTrustStore: - """Trust store implementation backed by a directory of PEM files.""" - - def __init__(self, path: str): - """Initialize the file trust store.""" - self.path = path - - def get_trust_anchors(self) -> List[str]: - """Retrieve trust anchors from the directory.""" - anchors = [] - if not os.path.isdir(self.path): - LOGGER.warning("Trust store path %s is not a directory.", self.path) - return anchors - - for filename in os.listdir(self.path): - if filename.endswith(".pem") or filename.endswith(".crt"): - try: - with open(os.path.join(self.path, filename), "r") as f: - anchors.append(f.read()) - except Exception as e: - LOGGER.warning("Failed to read trust anchor %s: %s", filename, e) - return anchors - - class WalletTrustStore: """Trust store implementation backed by Askar wallet storage. diff --git a/oid4vc/mso_mdoc/tests/test_review_issues.py b/oid4vc/mso_mdoc/tests/test_review_issues.py index 4637fae1b..834307202 100644 --- a/oid4vc/mso_mdoc/tests/test_review_issues.py +++ b/oid4vc/mso_mdoc/tests/test_review_issues.py @@ -40,7 +40,6 @@ # Now import the modules under test. # --------------------------------------------------------------------------- from ..mdoc.verifier import ( # noqa: E402 - FileTrustStore, MsoMdocCredVerifier, MsoMdocPresVerifier, WalletTrustStore, @@ -131,7 +130,7 @@ async def test_no_trust_store_passes_empty_registry(self): @pytest.mark.asyncio async def test_empty_trust_store_passes_empty_registry(self): """verify_presentation with a trust_store returning [] must also fail-closed.""" - mock_store = MagicMock(spec=FileTrustStore) + mock_store = MagicMock() mock_store.get_trust_anchors.return_value = [] verifier = MsoMdocPresVerifier(trust_store=mock_store) profile, _ = make_mock_profile() diff --git a/oid4vc/mso_mdoc/tests/test_verifier.py b/oid4vc/mso_mdoc/tests/test_verifier.py index f7a2b7c6d..eb5451c22 100644 --- a/oid4vc/mso_mdoc/tests/test_verifier.py +++ b/oid4vc/mso_mdoc/tests/test_verifier.py @@ -2,14 +2,13 @@ import sys from contextlib import asynccontextmanager -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import MagicMock, patch import pytest from oid4vc.models.presentation import OID4VPPresentation from ..mdoc.verifier import ( - FileTrustStore, MsoMdocCredVerifier, MsoMdocPresVerifier, PreverifiedMdocClaims, @@ -48,122 +47,6 @@ async def mock_session_context(): return profile, mock_session -class TestFileTrustStore: - """Test FileTrustStore functionality.""" - - def test_init_stores_path(self): - """Test that initialization stores the path correctly.""" - store = FileTrustStore("/some/path") - assert store.path == "/some/path" - - def test_get_trust_anchors_success(self): - """Test retrieving trust anchors successfully.""" - with ( - patch("os.path.isdir", return_value=True), - patch("os.listdir", return_value=["cert1.pem", "cert2.crt", "ignore.txt"]), - patch("builtins.open", mock_open(read_data="CERT_CONTENT")), - ): - store = FileTrustStore("/path/to/certs") - anchors = store.get_trust_anchors() - - assert len(anchors) == 2 - assert anchors == ["CERT_CONTENT", "CERT_CONTENT"] - - def test_get_trust_anchors_no_dir(self): - """Test handling of missing directory.""" - with patch("os.path.isdir", return_value=False): - store = FileTrustStore("/invalid/path") - anchors = store.get_trust_anchors() - assert anchors == [] - - def test_get_trust_anchors_read_error(self): - """Test handling of file read errors.""" - with ( - patch("os.path.isdir", return_value=True), - patch("os.listdir", return_value=["cert1.pem"]), - patch("builtins.open", side_effect=Exception("Read error")), - ): - store = FileTrustStore("/path/to/certs") - anchors = store.get_trust_anchors() - assert anchors == [] - - def test_get_trust_anchors_empty_directory(self): - """Test handling of empty directory with no certificate files.""" - with ( - patch("os.path.isdir", return_value=True), - patch("os.listdir", return_value=[]), - ): - store = FileTrustStore("/path/to/empty") - anchors = store.get_trust_anchors() - assert anchors == [] - - def test_get_trust_anchors_only_non_cert_files(self): - """Test directory with only non-certificate files.""" - with ( - patch("os.path.isdir", return_value=True), - patch("os.listdir", return_value=["readme.txt", "config.json", "script.sh"]), - ): - store = FileTrustStore("/path/to/certs") - anchors = store.get_trust_anchors() - assert anchors == [] - - def test_get_trust_anchors_partial_read_failure(self): - """Test that successful reads continue after a failed read.""" - - def mock_open_side_effect(path, mode="r"): - if "fail" in path: - raise Exception("Read error") - return mock_open(read_data="CERT_CONTENT")() - - with ( - patch("os.path.isdir", return_value=True), - patch("os.listdir", return_value=["good1.pem", "fail.pem", "good2.crt"]), - patch("builtins.open", side_effect=mock_open_side_effect), - ): - store = FileTrustStore("/path/to/certs") - anchors = store.get_trust_anchors() - - # Should have 2 successful reads despite 1 failure - assert len(anchors) == 2 - assert all(a == "CERT_CONTENT" for a in anchors) - - def test_get_trust_anchors_case_sensitive_extensions(self): - """Test that file extension matching is case-sensitive.""" - with ( - patch("os.path.isdir", return_value=True), - patch("os.listdir", return_value=["cert1.PEM", "cert2.CRT", "cert3.pem"]), - patch("builtins.open", mock_open(read_data="CERT_CONTENT")), - ): - store = FileTrustStore("/path/to/certs") - anchors = store.get_trust_anchors() - - # Only .pem (lowercase) should be matched, not .PEM or .CRT - assert len(anchors) == 1 - - def test_get_trust_anchors_reads_different_content(self): - """Test that different certificate files have different content.""" - file_contents = { - "/path/to/certs/cert1.pem": "CERT_ONE", - "/path/to/certs/cert2.crt": "CERT_TWO", - } - - def mock_open_with_content(path, mode="r"): - content = file_contents.get(path, "UNKNOWN") - return mock_open(read_data=content)() - - with ( - patch("os.path.isdir", return_value=True), - patch("os.listdir", return_value=["cert1.pem", "cert2.crt"]), - patch("builtins.open", side_effect=mock_open_with_content), - ): - store = FileTrustStore("/path/to/certs") - anchors = store.get_trust_anchors() - - assert len(anchors) == 2 - assert "CERT_ONE" in anchors - assert "CERT_TWO" in anchors - - class TestMsoMdocCredVerifier: """Test MsoMdocCredVerifier functionality.""" diff --git a/oid4vc/mso_mdoc/tests/test_wallet_trust_store_per_request.py b/oid4vc/mso_mdoc/tests/test_wallet_trust_store_per_request.py index 67c5561a3..4fe58a329 100644 --- a/oid4vc/mso_mdoc/tests/test_wallet_trust_store_per_request.py +++ b/oid4vc/mso_mdoc/tests/test_wallet_trust_store_per_request.py @@ -1,22 +1,8 @@ -"""Tests for the sub-wallet trust-store isolation fix. - -BUG (fixed): - ``_mso_mdoc_processor`` is a module-level singleton. At startup a - ``WalletTrustStore(root_profile)`` is attached to it. When a sub-wallet - request arrives, ``verify_presentation`` / ``verify_credential`` forward - ``self.trust_store`` — which still holds the root profile — to the - verifier. ``refresh_cache()`` therefore queries the root wallet's Askar - store, making any trust anchors registered via - ``POST /mso_mdoc/trust-anchors`` with a sub-wallet Bearer invisible. - -FIX: - When ``OID4VC_MDOC_TRUST_STORE_TYPE=wallet``, both methods now construct a - fresh ``WalletTrustStore(profile)`` from the *calling* profile rather than - forwarding ``self.trust_store``. For file- and None-based stores the - singleton is still reused. - -HOW TO RUN: - pytest mso_mdoc/tests/test_wallet_trust_store_per_request.py -v +"""Tests for per-request wallet-scoped trust store isolation. + +Trust anchors are always stored in the Askar wallet; each call to +verify_credential / verify_presentation builds a fresh WalletTrustStore from +the *calling* profile so that sub-wallet tenants see only their own registry. """ import sys @@ -52,28 +38,25 @@ async def _session(): return profile -def _make_processor(root_trust_store: MagicMock) -> MsoMdocCredProcessor: - """Return a processor with a singleton trust store simulating startup state.""" - processor = MsoMdocCredProcessor() - processor.trust_store = root_trust_store - return processor +def _make_processor() -> MsoMdocCredProcessor: + """Return a fresh processor (trust store is always built per-request).""" + return MsoMdocCredProcessor() # --------------------------------------------------------------------------- -# verify_presentation — wallet mode +# verify_presentation — wallet-scoped per-request # --------------------------------------------------------------------------- class TestVerifyPresentationWalletTrustStorePerRequest: - """verify_presentation must build a per-request WalletTrustStore when - OID4VC_MDOC_TRUST_STORE_TYPE=wallet.""" + """verify_presentation must build a per-request WalletTrustStore from the + calling profile on every call, keeping tenant registries isolated.""" @pytest.mark.asyncio - async def test_uses_calling_profile_not_singleton(self, monkeypatch): - """A fresh WalletTrustStore(profile) must be constructed with the - sub-wallet profile, not forwarded from self.trust_store.""" - root_trust_store = MagicMock(name="root_trust_store") - processor = _make_processor(root_trust_store) + async def test_uses_calling_profile(self): + """A fresh WalletTrustStore(profile) must be built from the calling + profile on every verify_presentation call.""" + processor = _make_processor() sub_profile = _make_profile("tenant-123") pres_record = MagicMock() @@ -88,13 +71,9 @@ def __init__(self, profile): return_value=MagicMock(verified=True) ) - monkeypatch.setenv("OID4VC_MDOC_TRUST_STORE_TYPE", "wallet") - with ( patch( - "mso_mdoc.cred_processor.MsoMdocCredProcessor.verify_presentation.__wrapped__" - if False - else "mso_mdoc.cred_processor.WalletTrustStore", + "mso_mdoc.cred_processor.WalletTrustStore", FakeWalletTrustStore, ), patch( @@ -109,16 +88,19 @@ def __init__(self, profile): ) assert captured_profiles[0] is sub_profile, ( "WalletTrustStore must be constructed with the calling (sub-wallet) " - "profile, not the root profile from the singleton trust store.\n" + "profile.\n" f"Got: {captured_profiles[0]!r}\nExpected: {sub_profile!r}" ) @pytest.mark.asyncio - async def test_does_not_use_singleton_trust_store(self, monkeypatch): - """self.trust_store (root profile) must NOT be passed to the verifier - when OID4VC_MDOC_TRUST_STORE_TYPE=wallet.""" - root_trust_store = MagicMock(name="root_trust_store") - processor = _make_processor(root_trust_store) + async def test_does_not_reuse_stale_trust_store(self): + """self.trust_store (if set) must NOT be passed directly to the verifier; + a fresh WalletTrustStore built from the calling profile is always used.""" + processor = _make_processor() + # Simulate a stale/root trust store on the processor (legacy state) + stale_trust_store = MagicMock(name="stale_root_trust_store") + processor.trust_store = stale_trust_store + sub_profile = _make_profile("tenant-456") pres_record = MagicMock() @@ -131,12 +113,10 @@ def __init__(self, trust_store=None): async def verify_presentation(self, *args, **kwargs): return MagicMock(verified=True) - monkeypatch.setenv("OID4VC_MDOC_TRUST_STORE_TYPE", "wallet") - with ( patch( "mso_mdoc.cred_processor.WalletTrustStore", - lambda profile: f"ws({profile})", + lambda profile: f"ws({id(profile)})", ), patch( "mso_mdoc.cred_processor.MsoMdocPresVerifier", @@ -146,91 +126,79 @@ async def verify_presentation(self, *args, **kwargs): await processor.verify_presentation(sub_profile, {}, pres_record) assert len(trust_stores_passed) == 1 - assert trust_stores_passed[0] is not root_trust_store, ( - "The singleton root trust store must NOT be forwarded to the verifier " - "in wallet mode. The verifier received self.trust_store instead of " - "a fresh WalletTrustStore(calling_profile)." + assert trust_stores_passed[0] is not stale_trust_store, ( + "A stale root trust store must NOT be forwarded to the verifier. " + "A fresh WalletTrustStore(calling_profile) must always be used." ) - @pytest.mark.asyncio - async def test_file_mode_reuses_singleton(self, monkeypatch): - """In file mode the singleton self.trust_store must be reused — no new - WalletTrustStore is constructed.""" - root_trust_store = MagicMock(name="file_trust_store") - processor = _make_processor(root_trust_store) - sub_profile = _make_profile("tenant-789") - pres_record = MagicMock() - - trust_stores_passed: list = [] - class CapturingPresVerifier: - def __init__(self, trust_store=None): - trust_stores_passed.append(trust_store) - - async def verify_presentation(self, *args, **kwargs): - return MagicMock(verified=True) - - monkeypatch.setenv("OID4VC_MDOC_TRUST_STORE_TYPE", "file") +# --------------------------------------------------------------------------- +# Isolation: two concurrent sub-wallet calls get independent trust stores +# --------------------------------------------------------------------------- - with patch( - "mso_mdoc.cred_processor.MsoMdocPresVerifier", - CapturingPresVerifier, - ): - await processor.verify_presentation(sub_profile, {}, pres_record) - assert len(trust_stores_passed) == 1 - assert trust_stores_passed[0] is root_trust_store, ( - "In file mode, the singleton trust store must be reused." - ) +class TestConcurrentSubWalletIsolation: + """Each concurrent sub-wallet call must get its own WalletTrustStore so + cache refreshes in one tenant do not affect another.""" @pytest.mark.asyncio - async def test_default_env_reuses_singleton(self, monkeypatch): - """Without OID4VC_MDOC_TRUST_STORE_TYPE set the default is 'file' and - the singleton must be reused.""" - root_trust_store = MagicMock(name="default_trust_store") - processor = _make_processor(root_trust_store) - sub_profile = _make_profile() - pres_record = MagicMock() - - trust_stores_passed: list = [] + async def test_independent_trust_stores_per_call(self): + """Two concurrent verify_presentation calls with different profiles + must each receive a WalletTrustStore built from their own profile.""" + processor = _make_processor() - class CapturingPresVerifier: - def __init__(self, trust_store=None): - trust_stores_passed.append(trust_store) + profile_a = _make_profile("tenant-A") + profile_b = _make_profile("tenant-B") + pres_record = MagicMock() - async def verify_presentation(self, *args, **kwargs): - return MagicMock(verified=True) + wts_calls: list = [] - monkeypatch.delenv("OID4VC_MDOC_TRUST_STORE_TYPE", raising=False) + def fake_wts(profile): + wts_calls.append(profile) + return MagicMock(name=f"wts-{profile.settings['wallet.id']}") - with patch( - "mso_mdoc.cred_processor.MsoMdocPresVerifier", - CapturingPresVerifier, + with ( + patch("mso_mdoc.cred_processor.WalletTrustStore", fake_wts), + patch("mso_mdoc.cred_processor.MsoMdocPresVerifier") as mock_cls, ): - await processor.verify_presentation(sub_profile, {}, pres_record) + mock_cls.return_value.verify_presentation = AsyncMock( + return_value=MagicMock(verified=True) + ) + import asyncio - assert trust_stores_passed[0] is root_trust_store, ( - "Default (file) mode must reuse the singleton trust store." - ) + await asyncio.gather( + processor.verify_presentation(profile_a, {}, pres_record), + processor.verify_presentation(profile_b, {}, pres_record), + ) + + assert len(wts_calls) == 2, "Each call must construct its own WalletTrustStore" + profiles_seen = {id(p) for p in wts_calls} + assert id(profile_a) in profiles_seen + assert id(profile_b) in profiles_seen # --------------------------------------------------------------------------- -# verify_credential — wallet mode +# verify_credential — wallet-scoped per-request # --------------------------------------------------------------------------- class TestVerifyCredentialWalletTrustStorePerRequest: - """verify_credential must build a per-request WalletTrustStore when - OID4VC_MDOC_TRUST_STORE_TYPE=wallet.""" + """verify_credential must build a per-request WalletTrustStore from the + calling profile on every call.""" @pytest.mark.asyncio - async def test_uses_calling_profile_not_singleton(self, monkeypatch): - """A fresh WalletTrustStore(profile) must be constructed with the - sub-wallet profile.""" - root_trust_store = MagicMock(name="root_trust_store") - processor = _make_processor(root_trust_store) + async def test_uses_calling_profile(self): + """A fresh WalletTrustStore(profile) must be built from the calling + profile on every verify_credential call.""" + processor = _make_processor() sub_profile = _make_profile("cred-tenant-1") + captured_wts_profiles: list = [] + + def fake_wts(profile): + captured_wts_profiles.append(profile) + return f"wts({id(profile)})" + trust_stores_passed: list = [] class CapturingCredVerifier: @@ -240,14 +208,6 @@ def __init__(self, trust_store=None): async def verify_credential(self, *args, **kwargs): return MagicMock(verified=True) - captured_wts_profiles: list = [] - - def fake_wts(profile): - captured_wts_profiles.append(profile) - return f"wts({id(profile)})" - - monkeypatch.setenv("OID4VC_MDOC_TRUST_STORE_TYPE", "wallet") - with ( patch("mso_mdoc.cred_processor.WalletTrustStore", fake_wts), patch( @@ -259,88 +219,5 @@ def fake_wts(profile): assert len(captured_wts_profiles) == 1 assert captured_wts_profiles[0] is sub_profile, ( - "verify_credential must construct WalletTrustStore with the calling " - "profile, not the root profile singleton." + "verify_credential must construct WalletTrustStore with the calling profile." ) - assert trust_stores_passed[0] is not root_trust_store, ( - "The singleton root trust store must NOT be forwarded in wallet mode." - ) - - @pytest.mark.asyncio - async def test_file_mode_reuses_singleton(self, monkeypatch): - """In file mode the singleton self.trust_store must be reused.""" - root_trust_store = MagicMock(name="file_trust_store") - processor = _make_processor(root_trust_store) - sub_profile = _make_profile("cred-tenant-2") - - trust_stores_passed: list = [] - - class CapturingCredVerifier: - def __init__(self, trust_store=None): - trust_stores_passed.append(trust_store) - - async def verify_credential(self, *args, **kwargs): - return MagicMock(verified=True) - - monkeypatch.setenv("OID4VC_MDOC_TRUST_STORE_TYPE", "file") - - with patch( - "mso_mdoc.cred_processor.MsoMdocCredVerifier", - CapturingCredVerifier, - ): - await processor.verify_credential(sub_profile, "raw-credential") - - assert trust_stores_passed[0] is root_trust_store, ( - "In file mode, the singleton trust store must be reused." - ) - - -# --------------------------------------------------------------------------- -# Isolation: two concurrent sub-wallet calls get independent trust stores -# --------------------------------------------------------------------------- - - -class TestConcurrentSubWalletIsolation: - """Each concurrent sub-wallet call must get its own WalletTrustStore so - cache refreshes in one tenant don't affect another.""" - - @pytest.mark.asyncio - async def test_independent_trust_stores_per_call(self, monkeypatch): - """Two concurrent verify_presentation calls with different profiles - must each receive a WalletTrustStore built from their own profile.""" - root_trust_store = MagicMock(name="root_trust_store") - processor = _make_processor(root_trust_store) - - profile_a = _make_profile("tenant-A") - profile_b = _make_profile("tenant-B") - pres_record = MagicMock() - - wts_calls: list = [] - - def fake_wts(profile): - wts_calls.append(profile) - return MagicMock(name=f"wts-{profile.settings['wallet.id']}") - - async def fake_verify(*args, **kwargs): - return MagicMock(verified=True) - - monkeypatch.setenv("OID4VC_MDOC_TRUST_STORE_TYPE", "wallet") - - with ( - patch("mso_mdoc.cred_processor.WalletTrustStore", fake_wts), - patch("mso_mdoc.cred_processor.MsoMdocPresVerifier") as mock_verifier_cls, - ): - mock_verifier_cls.return_value.verify_presentation = AsyncMock( - return_value=MagicMock(verified=True) - ) - import asyncio - - await asyncio.gather( - processor.verify_presentation(profile_a, {}, pres_record), - processor.verify_presentation(profile_b, {}, pres_record), - ) - - assert len(wts_calls) == 2, "Each call must construct its own WalletTrustStore" - profiles_seen = {id(p) for p in wts_calls} - assert id(profile_a) in profiles_seen - assert id(profile_b) in profiles_seen