Skip to content

Commit

Permalink
Merge pull request #3 from CQEN-QDCE/mdl
Browse files Browse the repository at this point in the history
Add mDL support to OID4VCI plugin.
  • Loading branch information
foxbike authored May 23, 2024
2 parents d1362b0 + 03db745 commit cf04721
Show file tree
Hide file tree
Showing 10 changed files with 1,261 additions and 813 deletions.
Binary file added .DS_Store
Binary file not shown.
2 changes: 1 addition & 1 deletion oid4vci/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@

"mounts": [],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [3000, 3001],
"forwardPorts": [3000, 3001, 8081],
"postCreateCommand": "bash ./.devcontainer/post-install.sh"
}
5 changes: 3 additions & 2 deletions oid4vci/docker/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ endpoint:
# plugins
plugin:
- oid4vci
- mso_mdoc

#config
genesis-url: https://indy.igrant.io/genesis
Expand All @@ -27,10 +28,10 @@ genesis-url: https://indy.igrant.io/genesis
# multitenant-admin: true

# Wallet
# wallet-name: default
wallet-name: default
wallet-type: askar
wallet-storage-type: default
# wallet-key: "insecure, for use in demo only"
wallet-key: "insecure, for use in demo only"

log-level: info

Expand Down
6 changes: 6 additions & 0 deletions oid4vci/oid4vci/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ def from_settings(cls, settings: BaseSettings) -> "Config":
port = int(plugin_settings.get("port") or getenv("OID4VCI_PORT", "0"))
endpoint = plugin_settings.get("endpoint") or getenv("OID4VCI_ENDPOINT")

if not host:
host = "0.0.0.0"
if not port:
port = 8081
if not endpoint:
endpoint = "http://localhost:8081"
if not host:
raise ConfigError("host", "OID4VCI_HOST")
if not port:
Expand Down
13 changes: 11 additions & 2 deletions oid4vci/oid4vci/models/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ def __init__(
exchange_id: Optional[str] = None,
state: str,
supported_cred_id: str,
credential_subject: Dict[str, Any],
credential_subject: Dict[str, Any] = None,
claims: Dict[str, Any] = None,
verification_method: str,
nonce: Optional[str] = None,
pin: Optional[str] = None,
Expand All @@ -47,6 +48,7 @@ def __init__(
super().__init__(exchange_id, state or "init", **kwargs)
self.supported_cred_id = supported_cred_id
self.credential_subject = credential_subject # (received from submit)
self.claims = claims # (received from submit)
self.verification_method = verification_method
self.nonce = nonce # in offer
self.pin = pin # (when relevant)
Expand All @@ -66,6 +68,7 @@ def record_value(self) -> dict:
for prop in (
"supported_cred_id",
"credential_subject",
"claims",
"verification_method",
"nonce",
"pin",
Expand Down Expand Up @@ -99,11 +102,17 @@ class Meta:
},
)
credential_subject = fields.Dict(
required=True,
required=False,
metadata={
"description": "desired claim and value in credential",
},
)
claims = fields.Dict(
required=False,
metadata={
"description": "Desired claims and values in mso_mdoc credential format",
},
)
verification_method = fields.Str(
required=True,
validate=Uri(),
Expand Down
30 changes: 29 additions & 1 deletion oid4vci/oid4vci/models/supported_cred.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ def __init__(
*,
supported_cred_id: Optional[str] = None,
format: Optional[str] = None,
doc_type: Optional[str] = None,
identifier: Optional[str] = None,
cryptographic_binding_methods_supported: Optional[List[str]] = None,
cryptographic_suites_supported: Optional[List[str]] = None,
display: Optional[List[Dict]] = None,
format_data: Optional[Dict] = None,
claims: Optional[Dict] = None,
vc_additional_data: Optional[Dict] = None,
**kwargs,
):
Expand All @@ -50,12 +52,14 @@ def __init__(
"""
super().__init__(supported_cred_id, **kwargs)
self.format = format
self.doc_type = doc_type
self.identifier = identifier
self.cryptographic_binding_methods_supported = (
cryptographic_binding_methods_supported
)
self.cryptographic_suites_supported = cryptographic_suites_supported
self.display = display
self.claims = claims
self.format_data = format_data
self.vc_additional_data = vc_additional_data

Expand All @@ -71,11 +75,13 @@ def record_value(self) -> dict:
prop: getattr(self, prop)
for prop in (
"format",
"doc_type",
"identifier",
"cryptographic_binding_methods_supported",
"cryptographic_suites_supported",
"display",
"format_data",
"claims",
"vc_additional_data",
)
}
Expand All @@ -91,6 +97,7 @@ def to_issuer_metadata(self) -> dict:
prop: value
for prop in (
"format",
"doc_type",
"cryptographic_binding_methods_supported",
"cryptographic_suites_supported",
"display",
Expand All @@ -104,6 +111,7 @@ def to_issuer_metadata(self) -> dict:
issuer_metadata = {
**issuer_metadata,
**(self.format_data or {}),
**(self.claims or {}),
}
return issuer_metadata

Expand All @@ -120,7 +128,8 @@ class Meta:
required=False,
description="supported credential identifier",
)
format = fields.Str(required=True, metadata={"example": "jwt_vc_json"})
format = fields.Str(required=True, metadata={"example": "jwt_vc_json or mso_mdoc"})
doc_type = fields.Str(required=False, metadata={"example": "org.iso.18013.5.1.mDL"})
identifier = fields.Str(
required=True, metadata={"example": "UniversityDegreeCredential"}
)
Expand Down Expand Up @@ -162,6 +171,25 @@ class Meta:
}
},
)
claims = fields.Dict(
required=False,
metadata={
"example": {
"org.iso.18013.5.1": {
"given_name": {
"display": [{"name": "Given Name", "locale": "en-US"}]
},
"family_name": {
"display": [{"name": "Surname", "locale": "en-US"}]
},
"birth_date": {}
},
"org.iso.18013.5.1.aamva": {
"organ_donor": {}
}
}
},
)
vc_additional_data = fields.Dict(
required=False,
metadata={
Expand Down
142 changes: 124 additions & 18 deletions oid4vci/oid4vci/public_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from secrets import token_urlsafe
from typing import Any, Dict, List, Mapping, Optional
import uuid

from binascii import unhexlify, hexlify
from aiohttp import web
from aiohttp_apispec import docs, form_schema, request_schema, response_schema
from aries_cloudagent.admin.request_context import AdminRequestContext
Expand All @@ -17,6 +17,10 @@
from aries_cloudagent.storage.error import StorageError, StorageNotFoundError
from aries_cloudagent.wallet.base import WalletError
from aries_cloudagent.wallet.error import WalletNotFoundError
import os
import cbor2
from mso_mdoc.v1_0.mdoc import mso_mdoc_sign, mso_mdoc_verify

from aries_cloudagent.wallet.jwt import (
JWTVerifyResult,
b64_to_dict,
Expand All @@ -32,12 +36,45 @@
from .config import Config
from .models.exchange import OID4VCIExchangeRecord
from .models.supported_cred import SupportedCredential
from pycose.keys import CoseKey, EC2Key
from base64 import urlsafe_b64decode

LOGGER = logging.getLogger(__name__)
PRE_AUTHORIZED_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code"
NONCE_BYTES = 16
EXPIRES_IN = 86400

class AuthorizationServerMetadataSchema(OpenAPISchema):
"""Authorization Server metadata schema."""

issuer = fields.Str(
required=True,
metadata={"description": "The authorization server's issuer identifier."},
)
authorization_endpoint = fields.Str(
required=True,
metadata={"description": "URL of the authorization server's authorization endpoint."},
)
token_endpoint = fields.Str(
required=True,
metadata={"description": "URL of the authorization server's token endpoint."},
)

@docs(tags=["oid4vci"], summary="Get authorization Server metadata")
@response_schema(AuthorizationServerMetadataSchema())
async def authorization_server_metadata(request: web.Request):
"""Authorization Server metadata endpoint."""
context: AdminRequestContext = request["context"]
config = Config.from_settings(context.settings)
public_url = config.endpoint

metadata = {
"issuer": f"{public_url}/",
"authorization_endpoint": f"{public_url}/authorization",
"token_endpoint": f"{public_url}/token",
}

return web.json_response(metadata)

class CredentialIssuerMetadataSchema(OpenAPISchema):
"""Credential issuer metadata schema."""
Expand Down Expand Up @@ -73,7 +110,7 @@ async def credential_issuer_metadata(request: web.Request):
context: AdminRequestContext = request["context"]
config = Config.from_settings(context.settings)
public_url = config.endpoint

async with context.session() as session:
# TODO If there's a lot, this will be a problem
credentials_supported = await SupportedCredential.query(session)
Expand Down Expand Up @@ -302,6 +339,7 @@ async def issue_cred(request: web.Request):
As validated upon presentation of a valid Access Token.
"""

context: AdminRequestContext = request["context"]
token_result = await check_token(
context.profile, request.headers.get("Authorization")
Expand All @@ -318,22 +356,6 @@ async def issue_cred(request: web.Request):
except (StorageError, BaseModelError, StorageNotFoundError) as err:
raise web.HTTPBadRequest(reason=err.roll_up) from err

if supported.format != "jwt_vc_json":
raise web.HTTPUnprocessableEntity(reason="Only jwt_vc_json is supported.")
if supported.format_data is None:
LOGGER.error("No format_data for supported credential of format jwt_vc_json")
raise web.HTTPInternalServerError()

if supported.format != body.get("format"):
raise web.HTTPBadRequest(reason="Requested format does not match offer.")
if not types_are_subset(body.get("types"), supported.format_data.get("types")):
raise web.HTTPBadRequest(reason="Requested types does not match offer.")

current_time = datetime.datetime.now(datetime.timezone.utc)
current_time_unix_timestamp = int(current_time.timestamp())
formatted_time = current_time.strftime("%Y-%m-%dT%H:%M:%SZ")

cred_id = str(uuid.uuid4())
if "proof" not in body:
raise web.HTTPBadRequest(reason="proof is required for jwt_vc_json")
if ex_record.nonce is None:
Expand All @@ -344,9 +366,58 @@ async def issue_cred(request: web.Request):
pop = await handle_proof_of_posession(
context.profile, body["proof"], ex_record.nonce
)

if not pop.verified:
raise web.HTTPBadRequest(reason="Invalid proof")

if supported.format != body.get("format"):
raise web.HTTPBadRequest(reason="Requested format does not match offer.")

if supported.format == "mso_mdoc":
if body.get("doctype") != 'org.iso.18013.5.1.mDL':
raise web.HTTPBadRequest(reason="Only 'org.iso.18013.5.1.mDL' is supported.")
return await issue_mso_mdoc_cred(ex_record, context, pop)
if supported.format == "jwt_vc_json":
if not types_are_subset(body.get("types"), supported.format_data.get("types")):
raise web.HTTPBadRequest(reason="Requested types does not match offer.")
return await issue_jwt_vc_json_cred(ex_record, supported, context, pop)

raise web.HTTPUnprocessableEntity(reason="Only mso_mdoc and jwt_vc_json are supported.")

class DeviceKey:
""" . """
@classmethod
def from_pop(cls, pop: PopResult) -> CoseKey:
"""
Initialize a COSE key from a proof of possession result.
:param pop: Proof of possession result to translate to COSE key.
:return: An initialized COSE Key object.
"""

x_bytes = urlsafe_b64decode(pop.holder_jwk['x'] + '=' * (4 - len(pop.holder_jwk['x']) % 4))
y_bytes = urlsafe_b64decode(pop.holder_jwk['y'] + '=' * (4 - len(pop.holder_jwk['y']) % 4))
holder_key = {
'KTY': pop.holder_jwk['kty'] + '2',
'CURVE': pop.holder_jwk['crv'].replace('-', '_'),
'X': x_bytes,
'Y': y_bytes
}
return CoseKey.from_dict(holder_key)

async def issue_jwt_vc_json_cred(ex_record: OID4VCIExchangeRecord, supported: SupportedCredential, context: AdminRequestContext, pop: PopResult):
"""Issue an jwt_vc_json credential."""

if supported.format_data is None:
LOGGER.error("No format_data for supported credential of format jwt_vc_json")
raise web.HTTPInternalServerError()

current_time = datetime.datetime.now(datetime.timezone.utc)
current_time_unix_timestamp = int(current_time.timestamp())
formatted_time = current_time.strftime("%Y-%m-%dT%H:%M:%SZ")

cred_id = str(uuid.uuid4())

if not pop.holder_kid:
raise web.HTTPBadRequest(reason="No kid in proof; required for jwt_vc_json")

Expand Down Expand Up @@ -386,11 +457,46 @@ async def issue_cred(request: web.Request):
}
)

async def issue_mso_mdoc_cred(ex_record: OID4VCIExchangeRecord, context: AdminRequestContext, pop: PopResult):
"""Issue an mso_mdoc credential."""

headers = {
"deviceKey": cbor2.loads(DeviceKey.from_pop(pop).encode())
}
payload = ex_record.claims
try:
device_response_cbor_data = await mso_mdoc_sign(
context.profile, headers, payload, None, ex_record.verification_method
)
device_response = cbor2.loads(unhexlify(device_response_cbor_data[2:][:-1]))
mso_mdoc = str(hexlify(cbor2.dumps(device_response['documents'][0])), 'ascii')
except ValueError as err:
raise web.HTTPBadRequest(reason="Bad did or verification method") from err

async with context.session() as session:
ex_record.state = OID4VCIExchangeRecord.STATE_ISSUED
# Cause webhook to be emitted
await ex_record.save(session, reason="Credential issued")
# Exchange is completed, record can be cleaned up
# But we'll leave it to the controller
# await ex_record.delete_record(session)

return web.json_response(
{
"format": "mso_mdoc",
"credential": mso_mdoc,
}
)

async def register(app: web.Application):
"""Register routes."""
app.add_routes(
[
web.get(
"/.well-known/oauth-authorization-server",
authorization_server_metadata,
allow_head=False,
),
web.get(
"/.well-known/openid-credential-issuer",
credential_issuer_metadata,
Expand Down
Loading

0 comments on commit cf04721

Please sign in to comment.