diff --git a/idhub/models.py b/idhub/models.py index c1feadd..83f30fb 100644 --- a/idhub/models.py +++ b/idhub/models.py @@ -440,6 +440,7 @@ class DID(models.Model): related_name='dids', null=True, ) + # JSON-serialized DID document didweb_document = models.TextField() def get_key_material(self, password): @@ -589,6 +590,7 @@ class VerificableCredential(models.Model): on_delete=models.CASCADE, related_name='vcredentials', ) + revocationBitmapIndex = models.AutoField() def get_data(self, password): if not self.data: diff --git a/idhub/views.py b/idhub/views.py index 06aeed0..bea0712 100644 --- a/idhub/views.py +++ b/idhub/views.py @@ -1,6 +1,10 @@ +import base64 +import json import uuid import logging +import zlib +import pyroaring from django.conf import settings from django.core.cache import cache from django.urls import reverse_lazy @@ -12,7 +16,7 @@ from django.shortcuts import get_object_or_404, redirect from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponseRedirect, HttpResponse, Http404 -from idhub.models import DID +from idhub.models import DID, VerificableCredential from idhub.email.views import NotifyActivateUserByEmail from trustchain_idhub import settings @@ -99,9 +103,29 @@ class PasswordResetView(auth_views.PasswordResetView): def serve_did(request, did_id): - id_did = f'did:web:{settings.DOMAIN}:did-registry:{did_id}' + import urllib.parse + domain = urllib.parse.urlencode({"domain": settings.DOMAIN})[7:] + id_did = f'did:web:{domain}:did-registry:{did_id}' did = get_object_or_404(DID, did=id_did) - document = did.didweb_document + # Deserialize the base DID from JSON storage + document = json.loads(did.didweb_document) + # Has this DID issued any Verifiable Credentials? If so, we need to add a Revocation List "service" + # entry to the DID document. + revoked_credentials = did.vcredentials.filter(status=VerificableCredential.Status.REVOKED) + revoked_credential_indexes = [] + for credential in revoked_credentials: + revoked_credential_indexes.append(credential.revocationBitmapIndex) + # TODO: Conditionally add "service" to DID document only if the DID has issued any VC + revocation_bitmap = pyroaring.BitMap(revoked_credential_indexes) + encoded_revocation_bitmap = base64.b64encode(zlib.compress(revocation_bitmap.serialize())) + revocation_service = [{ # This is an object within a list. + "id": f"{id_did}#revocation", + "type": "RevocationBitmap2022", + "serviceEndpoint": f"data:application/octet-stream;base64,{encoded_revocation_bitmap}" + }] + document["service"] = revocation_service + # Serialize the DID + Revocation list in preparation for sending + document = json.dumps(document) retval = HttpResponse(document) retval.headers["Content-Type"] = "application/json" return retval diff --git a/requirements.txt b/requirements.txt index da0409f..6032a87 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,4 @@ ujson==5.9.0 openpyxl==3.1.2 jsonpath_ng==1.6.1 ./didkit-0.3.2-cp311-cp311-manylinux_2_34_x86_64.whl +pyroaring==0.4.5 \ No newline at end of file diff --git a/utils/idhub_ssikit/__init__.py b/utils/idhub_ssikit/__init__.py index 85e6e2f..81a2599 100644 --- a/utils/idhub_ssikit/__init__.py +++ b/utils/idhub_ssikit/__init__.py @@ -1,11 +1,16 @@ import asyncio +import base64 import datetime +import zlib +from ast import literal_eval + import didkit import json import urllib import jinja2 from django.template.backends.django import Template from django.template.loader import get_template +from pyroaring import BitMap from trustchain_idhub import settings @@ -18,8 +23,11 @@ def keydid_from_controller_key(key): return didkit.key_to_did("key", key) -async def resolve_keydid(keydid): - return await didkit.resolve_did(keydid, "{}") +def resolve_did(keydid): + async def inner(): + return await didkit.resolve_did(keydid, "{}") + + return asyncio.run(inner()) def webdid_from_controller_key(key): @@ -29,7 +37,7 @@ def webdid_from_controller_key(key): """ keydid = keydid_from_controller_key(key) # "did:key:<...>" pubkeyid = keydid.rsplit(":")[-1] # <...> - document = json.loads(asyncio.run(resolve_keydid(keydid))) # Documento DID en terminos "key" + document = json.loads(resolve_did(keydid)) # Documento DID en terminos "key" domain = urllib.parse.urlencode({"domain": settings.DOMAIN})[7:] webdid_url = f"did:web:{domain}:did-registry:{pubkeyid}" # nueva URL: "did:web:idhub.pangea.org:<...>" webdid_url_owner = webdid_url + "#owner" @@ -99,9 +107,37 @@ def verify_credential(vc): If it is false, the VC is invalid and the second argument contains a JSON object with further information. """ async def inner(): - return await didkit.verify_credential(vc, '{"proofFormat": "ldp"}') + str_res = await didkit.verify_credential(vc, '{"proofFormat": "ldp"}') + res = literal_eval(str_res) + ok = res["warnings"] == [] and res["errors"] == [] + return ok, str_res - return asyncio.run(inner()) + valid, reason = asyncio.run(inner()) + if not valid: + return valid, reason + # Credential passes basic signature verification. Now check it against its schema. + # TODO: check agasint schema + pass + # Credential verifies against its schema. Now check revocation status. + vc = json.loads(vc) + if "credentialStatus" in vc: + revocation_index = int(vc["credentialStatus"]["revocationBitmapIndex"]) # NOTE: THIS FIELD SHOULD BE SERIALIZED AS AN INTEGER, BUT IOTA DOCUMENTAITON SERIALIZES IT AS A STRING. DEFENSIVE CAST ADDED JUST IN CASE. + vc_issuer = vc["issuer"]["id"] # This is a DID + if vc_issuer[:7] == "did:web": # Only DID:WEB can revoke + issuer_did_document = json.loads(resolve_did(vc_issuer)) # TODO: implement a caching layer so we don't have to fetch the DID (and thus the revocation list) every time a VC is validated. + issuer_revocation_list = issuer_did_document["service"][0] + assert issuer_revocation_list["type"] == "RevocationBitmap2022" + revocation_bitmap = BitMap.deserialize( + zlib.decompress( + base64.b64decode( + issuer_revocation_list["serviceEndpoint"].rsplit(",")[1] + ) + ) + ) + if revocation_index in revocation_bitmap: + return False, "Credential has been revoked by the issuer" + # Fallthrough means all is good. + return True, "Credential passes all checks" def issue_verifiable_presentation(vp_template: Template, vc_list: list[str], jwk_holder: str, holder_did: str) -> str: