Skip to content

Commit 91eed74

Browse files
committed
feat(oid4vc): add mDOC (ISO 18013-5) credential issuance and verification
Implements OID4VCI mso_mdoc credential issuance and OID4VP mDOC presentation verification using the isomdl-uniffi Rust library. Key changes: - Rewrite mso_mdoc credential processor with isomdl-uniffi bindings - Add mDOC issuer (mdoc/issuer.py) and verifier (mdoc/verifier.py) - Add MSO issuer/verifier (consolidated from mso/ into mdoc/) - Add key generation routes for mDOC signing keys - Add storage layer: trust anchors, certificates, keys, config - Add x.509 cert chain handling and PEM splitting utilities - Add trust anchor guard (fail-closed) and cert expiry validation - Remove superseded mso/ package and x509.py (merged into mdoc/) - Update Docker/CI to install isomdl-uniffi platform wheel - Add OID4VC conformance tests GitHub Actions workflow - Fix ConnectError retry in integration test credo_client fixture Signed-off-by: Adam Burdett <burdettadam@gmail.com>
1 parent 447d9c4 commit 91eed74

28 files changed

Lines changed: 5183 additions & 591 deletions
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
name: OID4VC Conformance Tests
2+
# Runs the OIDF HAIP conformance suite against ACA-Py OID4VCI issuer and
3+
# OID4VP verifier. The suite is started from source inside Docker Compose and
4+
# all test results are written to a JUnit XML artifact.
5+
#
6+
# Trigger conditions:
7+
# - PR or push that touches oid4vc/** source files
8+
# - Manual run via workflow_dispatch (always runs regardless of changed files)
9+
on:
10+
pull_request:
11+
types: [opened, synchronize, reopened, ready_for_review]
12+
branches:
13+
- "**"
14+
paths:
15+
- "oid4vc/**"
16+
push:
17+
branches:
18+
- main
19+
paths:
20+
- "oid4vc/**"
21+
workflow_dispatch:
22+
23+
jobs:
24+
conformance-tests:
25+
name: "OID4VC Conformance Tests"
26+
runs-on: ubuntu-latest
27+
# Skip draft PRs (same policy as integration-tests)
28+
if: |
29+
github.event_name == 'workflow_dispatch' ||
30+
(github.event_name == 'push') ||
31+
(github.event_name == 'pull_request' && github.event.pull_request.draft == false)
32+
timeout-minutes: 90
33+
34+
steps:
35+
# ── Checkout ────────────────────────────────────────────────────────────
36+
- name: Check out repository
37+
uses: actions/checkout@v4
38+
39+
# ── Docker Buildx (enables layer cache via GitHub Actions cache) ────────
40+
- name: Set up Docker Buildx
41+
uses: docker/setup-buildx-action@v3
42+
43+
# ── Pre-build ACA-Py issuer image (Rust/isomdl, ~10 min cold) ──────────
44+
# Both issuer and verifier share the same Dockerfile; the verifier build
45+
# hits cache after the issuer build completes.
46+
- name: Build acapy-issuer image
47+
uses: docker/build-push-action@v6
48+
with:
49+
context: .
50+
file: oid4vc/docker/Dockerfile
51+
push: false
52+
load: true
53+
tags: oid4vc-integration-acapy-issuer:latest
54+
build-args: |
55+
ACAPY_VERSION=1.4.0
56+
ISOMDL_BRANCH=fix/python-build-system
57+
cache-from: type=gha,scope=acapy-oid4vc
58+
cache-to: type=gha,mode=max,scope=acapy-oid4vc
59+
60+
- name: Build acapy-verifier image
61+
uses: docker/build-push-action@v6
62+
with:
63+
context: .
64+
file: oid4vc/docker/Dockerfile
65+
push: false
66+
load: true
67+
tags: oid4vc-integration-acapy-verifier:latest
68+
build-args: |
69+
ACAPY_VERSION=1.4.0
70+
ISOMDL_BRANCH=fix/python-build-system
71+
# Issuer + verifier share all layers; use same cache scope.
72+
cache-from: type=gha,scope=acapy-oid4vc
73+
74+
# ── Pre-build OIDF conformance server (Maven build, ~15 min cold) ───────
75+
- name: Build conformance-server image
76+
uses: docker/build-push-action@v6
77+
with:
78+
context: oid4vc/integration/conformance
79+
file: oid4vc/integration/conformance/Dockerfile.server
80+
push: false
81+
load: true
82+
tags: oid4vc-integration-conformance-server:latest
83+
build-args: |
84+
CONFORMANCE_SUITE_BRANCH=master
85+
cache-from: type=gha,scope=conformance-server
86+
cache-to: type=gha,mode=max,scope=conformance-server
87+
88+
# ── Pre-build conformance runner (lightweight Python image) ─────────────
89+
- name: Build conformance-runner image
90+
uses: docker/build-push-action@v6
91+
with:
92+
context: oid4vc/integration
93+
file: oid4vc/integration/conformance/Dockerfile.runner
94+
push: false
95+
load: true
96+
tags: oid4vc-integration-conformance-runner:latest
97+
cache-from: type=gha,scope=conformance-runner
98+
cache-to: type=gha,mode=max,scope=conformance-runner
99+
100+
# ── Run conformance suite ────────────────────────────────────────────────
101+
# DOCKER_PLATFORM is detected automatically by the shell script based on
102+
# `uname -m`; set explicitly here to avoid any ambiguity on CI runners.
103+
- name: Run conformance tests
104+
env:
105+
DOCKER_PLATFORM: linux/amd64
106+
run: |
107+
bash oid4vc/integration/run-conformance-tests.sh run all
108+
109+
# ── Collect results ──────────────────────────────────────────────────────
110+
- name: Upload JUnit test results
111+
if: always()
112+
uses: actions/upload-artifact@v4
113+
with:
114+
name: conformance-junit-results
115+
path: oid4vc/integration/test-results/conformance-junit.xml
116+
if-no-files-found: warn
117+
118+
- name: Publish JUnit test summary
119+
if: always()
120+
uses: mikepenz/action-junit-report@v4
121+
with:
122+
report_paths: "oid4vc/integration/test-results/conformance-junit.xml"
123+
check_name: "OIDF Conformance Results"
124+
fail_on_failure: false
125+
require_tests: false
126+
127+
# ── Collect Docker logs on failure ───────────────────────────────────────
128+
- name: Dump Docker Compose logs
129+
if: failure()
130+
run: |
131+
mkdir -p /tmp/conformance-logs
132+
cd oid4vc/integration
133+
# Capture all service logs for post-mortem analysis
134+
docker compose --profile conformance logs --no-color \
135+
> /tmp/conformance-logs/docker-compose.log 2>&1 || true
136+
docker compose --profile conformance logs --no-color acapy-issuer \
137+
> /tmp/conformance-logs/acapy-issuer.log 2>&1 || true
138+
docker compose --profile conformance logs --no-color acapy-verifier \
139+
> /tmp/conformance-logs/acapy-verifier.log 2>&1 || true
140+
docker compose --profile conformance logs --no-color conformance-server \
141+
> /tmp/conformance-logs/conformance-server.log 2>&1 || true
142+
143+
- name: Upload Docker logs artifact
144+
if: failure()
145+
uses: actions/upload-artifact@v4
146+
with:
147+
name: conformance-docker-logs
148+
path: /tmp/conformance-logs/
149+
retention-days: 7

.github/workflows/pr-linting-and-unit-tests.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ jobs:
100100
#----------------------------------------------
101101
- name: Unit test plugins
102102
id: unit-tests
103-
continue-on-error: true
104103
run: |
105104
for dir in ${{ steps.changed-plugins.outputs.changed-plugins }}; do
106105
cd $dir
@@ -110,7 +109,6 @@ jobs:
110109
integration-tests:
111110
name: "Integration tests"
112111
runs-on: ubuntu-latest
113-
continue-on-error: true
114112
needs: linting-and-unit-tests
115113
if: needs.linting-and-unit-tests.result == 'success'
116114
steps:

oid4vc/docker/Dockerfile

Lines changed: 98 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,116 @@
1+
# =============================================================================
2+
# Stage 1: Build isomdl-uniffi wheel (requires Rust)
3+
# =============================================================================
4+
FROM python:3.12-slim-bookworm AS isomdl-build
5+
6+
WORKDIR /build
7+
8+
# Install build dependencies
9+
RUN apt-get update && apt-get install -y --no-install-recommends \
10+
curl \
11+
git \
12+
build-essential \
13+
&& rm -rf /var/lib/apt/lists/*
14+
15+
# Install Rust toolchain (minimal profile to save space)
16+
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
17+
ENV PATH="/root/.cargo/bin:${PATH}"
18+
19+
# Clone isomdl-uniffi with shallow clone
20+
ARG ISOMDL_BRANCH=fix/python-build-system
21+
RUN git clone --depth 1 --branch ${ISOMDL_BRANCH} \
22+
https://github.com/Indicio-tech/isomdl-uniffi.git /build/isomdl-uniffi
23+
24+
WORKDIR /build/isomdl-uniffi/python
25+
26+
# Build wheel — limit Cargo parallelism to avoid Docker VM OOM on resource-constrained hosts
27+
# (CARGO_BUILD_JOBS=2 cuts peak memory roughly in half vs. the default all-cores build)
28+
RUN pip install --no-cache-dir build wheel setuptools
29+
ENV CARGO_BUILD_JOBS=2
30+
RUN python setup.py bdist_wheel
31+
32+
# =============================================================================
33+
# Stage 2: Install ACA-Py and plugin dependencies
34+
# =============================================================================
135
FROM python:3.12-slim-bookworm AS base
36+
237
WORKDIR /usr/src/app
338

4-
# Install and configure poetry
5-
USER root
39+
# Install only required build/runtime dependencies (no Rust needed here)
40+
RUN apt-get update && apt-get install -y --no-install-recommends \
41+
curl \
42+
jq \
43+
git \
44+
&& rm -rf /var/lib/apt/lists/*
645

7-
# Install and configure poetry
8-
WORKDIR /usr/src/app
9-
ENV POETRY_VERSION=2.1.2
10-
ENV POETRY_HOME=/opt/poetry
11-
RUN apt-get update && apt-get install -y curl jq && apt-get clean
12-
RUN curl -sSL https://install.python-poetry.org | python -
46+
# Accept build argument for ACA-Py version
47+
ARG ACAPY_VERSION=1.4.0
1348

14-
ENV PATH="/opt/poetry/bin:$PATH"
15-
RUN poetry config virtualenvs.in-project true
49+
# Clone ACA-Py source with shallow clone
50+
RUN git clone --depth 1 --branch ${ACAPY_VERSION} \
51+
https://github.com/openwallet-foundation/acapy.git /usr/src/acapy
1652

17-
# Setup project
18-
RUN mkdir oid4vc && touch oid4vc/__init__.py
19-
RUN mkdir jwt_vc_json && touch jwt_vc_json/__init__.py
20-
RUN mkdir sd_jwt_vc && touch sd_jwt_vc/__init__.py
21-
RUN mkdir mso_mdoc && touch mso_mdoc/__init__.py
22-
COPY oid4vc/pyproject.toml oid4vc/poetry.lock oid4vc/README.md ./
23-
RUN poetry install --without dev --all-extras
24-
USER $user
53+
WORKDIR /usr/src/acapy
2554

26-
FROM python:3.12-bookworm
55+
# Install ACA-Py
56+
RUN pip install --no-cache-dir -e .
57+
RUN pip install --no-cache-dir configargparse
2758

59+
# Setup plugin project structure
2860
WORKDIR /usr/src/app
29-
COPY --from=base /usr/src/app/.venv /usr/src/app/.venv
30-
ENV PATH="/usr/src/app/.venv/bin:$PATH"
31-
RUN apt-get update && apt-get install -y curl jq && apt-get clean
61+
62+
# Copy the entire plugin source tree
63+
COPY oid4vc/pyproject.toml ./
64+
COPY oid4vc/README.md ./
65+
COPY oid4vc/oid4vc/ oid4vc/
3266
COPY oid4vc/jwt_vc_json/ jwt_vc_json/
3367
COPY oid4vc/mso_mdoc/ mso_mdoc/
3468
COPY oid4vc/sd_jwt_vc/ sd_jwt_vc/
35-
COPY oid4vc/oid4vc/ oid4vc/
3669
COPY status_list/ status_list/
3770
RUN pip install -e ./status_list
71+
72+
# Install isomdl-uniffi from builder stage
73+
COPY --from=isomdl-build /build/isomdl-uniffi/python/dist/*.whl /tmp/
74+
RUN pip install --no-cache-dir /tmp/*.whl && rm -rf /tmp/*.whl
75+
76+
# Install the plugin with extras for mso_mdoc and sd_jwt_vc
77+
RUN pip install --no-cache-dir -e ".[mso_mdoc,sd_jwt_vc]"
78+
79+
# =============================================================================
80+
# Stage 3: Final slim runtime image
81+
# =============================================================================
82+
FROM python:3.12-slim-bookworm
83+
84+
WORKDIR /usr/src/app
85+
86+
# Copy the complete environment from base stage
87+
COPY --from=base /usr/src/acapy /usr/src/acapy
88+
COPY --from=base /usr/src/app /usr/src/app
89+
90+
# Install only runtime dependencies
91+
RUN apt-get update && apt-get install -y --no-install-recommends \
92+
curl \
93+
jq \
94+
&& apt-get clean \
95+
&& rm -rf /var/lib/apt/lists/*
96+
97+
# Copy the entire Python environment from base stage, including site-packages
98+
COPY --from=base /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
99+
COPY --from=base /usr/local/bin /usr/local/bin
100+
101+
# Copy dev config
38102
RUN mkdir -p /usr/src/app/docker
39103
COPY oid4vc/docker/dev.yml /usr/src/app/docker/dev.yml
40104
COPY oid4vc/docker/dev-verifier.yml /usr/src/app/docker/dev-verifier.yml
41-
COPY oid4vc/docker/default.yml /usr/src/app/default.yml
105+
COPY oid4vc/docker/default.yml /usr/src/app/docker/default.yml
106+
107+
# Expose ports
108+
EXPOSE 8030 8031 8032
109+
110+
# Add health check
111+
HEALTHCHECK --interval=10s --timeout=5s --retries=12 --start-period=60s \
112+
CMD curl -f http://localhost:${ACAPY_ADMIN_PORT:-8021}/status/ready || exit 1
42113

43-
ENTRYPOINT ["/bin/bash", "-c", "aca-py \"$@\"", "--"]
44-
CMD ["start", "--arg-file", "default.yml"]
114+
# Set working directory and run ACA-Py
115+
WORKDIR /usr/src/acapy
116+
CMD ["python", "-m", "acapy_agent", "start", "--arg-file", "/usr/src/app/docker/dev.yml"]

oid4vc/docker/dev-verifier.yml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,11 @@ plugin:
2222
- sd_jwt_vc
2323
- mso_mdoc
2424

25-
# OID4VC plugin configuration - Use different ports for OID4VCI and OID4VP servers
25+
# OID4VC plugin configuration - both OID4VCI and OID4VP routes served on the same port
2626
plugin-config-value:
2727
- oid4vci.host=0.0.0.0
28-
- oid4vci.port=8033
29-
- oid4vci.endpoint=${OID4VCI_ENDPOINT:-http://localhost:8033}
30-
- oid4vp.host=0.0.0.0
31-
- oid4vp.port=8032
28+
- oid4vci.port=8032
29+
- oid4vci.endpoint=${OID4VCI_ENDPOINT:-http://localhost:8032}
3230
- oid4vp.endpoint=${OID4VP_ENDPOINT:-http://localhost:8032}
3331

3432
# Ledger configuration - use no-ledger for simple development

oid4vc/integration/tests/conftest.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,14 @@
4747
async def credo_client():
4848
"""HTTP client for Credo agent service."""
4949
async with httpx.AsyncClient(base_url=CREDO_AGENT_URL, timeout=30.0) as client:
50-
# Wait for service to be ready
51-
for _ in range(5): # Reduced since services should already be ready
52-
response = await client.get("/health")
53-
if response.status_code == 200:
54-
break
50+
# Wait for service to be ready (30 retries to handle brief unavailability)
51+
for _ in range(30):
52+
try:
53+
response = await client.get("/health")
54+
if response.status_code == 200:
55+
break
56+
except httpx.ConnectError:
57+
pass
5558
await asyncio.sleep(1)
5659
else:
5760
raise RuntimeError("Credo agent service not available")

0 commit comments

Comments
 (0)