diff --git a/idhub/templates/credentials/exo.json b/idhub/templates/credentials/exo.json new file mode 100644 index 0000000..1aee10b --- /dev/null +++ b/idhub/templates/credentials/exo.json @@ -0,0 +1,30 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "name": "https://schema.org/name", + "email": "https://schema.org/email", + "membershipType": "https://schema.org/memberOf", + "individual": "https://schema.org/Person", + "organization": "https://schema.org/Organization", + "Member": "https://schema.org/Member", + "startDate": "https://schema.org/startDate", + "jsonSchema": "https://schema.org/jsonSchema", + "street_address": "https://schema.org/streetAddress", + "connectivity_option_list": "https://schema.org/connectivityOptionList", + "$ref": "https://schema.org/jsonSchemaRef" + } + ], + "id": "{{ vc_id }}", + "type": ["VerifiableCredential", "HomeConnectivitySurveyCredential"], + "issuer": "{{ issuer_did }}", + "issuanceDate": "{{ issuance_date }}", + "credentialSubject": { + "id": "{{ subject_did }}", + "street_address": "{{ street_address }}", + "connectivity_option_list": "{{ connectivity_option_list }}", + "jsonSchema": { + "$ref": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/UNDEF.json" + } + } +} diff --git a/idhub/templates/credentials/openarms.json b/idhub/templates/credentials/openarms.json new file mode 100644 index 0000000..e2ecbc6 --- /dev/null +++ b/idhub/templates/credentials/openarms.json @@ -0,0 +1,33 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "name": "https://schema.org/name", + "email": "https://schema.org/email", + "membershipType": "https://schema.org/memberOf", + "individual": "https://schema.org/Person", + "organization": "https://schema.org/Organization", + "Member": "https://schema.org/Member", + "startDate": "https://schema.org/startDate", + "jsonSchema": "https://schema.org/jsonSchema", + "destination_country": "https://schema.org/destinationCountry", + "offboarding_date": "https://schema.org/offboardingDate", + "$ref": "https://schema.org/jsonSchemaRef" + } + ], + "id": "{{ vc_id }}", + "type": ["VerifiableCredential", "MigrantRescueCredential"], + "issuer": "{{ issuer_did }}", + "issuanceDate": "{{ issuance_date }}", + "credentialSubject": { + "id": "{{ subject_did }}", + "name": "{{ name }}", + "country_of_origin": "{{ country_of_origin }}", + "rescue_date": "{{ rescue_date }}", + "destination_country": "{{ destination_country }}", + "offboarding_date": "{{ offboarding_date }}", + "jsonSchema": { + "$ref": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/UNDEF.json" + } + } +} diff --git a/idhub/templates/credentials/paremanel.json b/idhub/templates/credentials/paremanel.json new file mode 100644 index 0000000..a40396b --- /dev/null +++ b/idhub/templates/credentials/paremanel.json @@ -0,0 +1,30 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "name": "https://schema.org/name", + "email": "https://schema.org/email", + "membershipType": "https://schema.org/memberOf", + "individual": "https://schema.org/Person", + "organization": "https://schema.org/Organization", + "Member": "https://schema.org/Member", + "startDate": "https://schema.org/startDate", + "jsonSchema": "https://schema.org/jsonSchema", + "street_address": "https://schema.org/streetAddress", + "financial_vulnerability_score": "https://schema.org/financialVulnerabilityScore", + "$ref": "https://schema.org/jsonSchemaRef" + } + ], + "id": "{{ vc_id }}", + "type": ["VerifiableCredential", "FinancialSituationCredential"], + "issuer": "{{ issuer_did }}", + "issuanceDate": "{{ issuance_date }}", + "credentialSubject": { + "id": "{{ subject_did }}", + "street_address": "{{ street_address }}", + "financial_vulnerability_score": "{{ financial_vulnerability_score }}", + "jsonSchema": { + "$ref": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/UNDEF.json" + } + } +} diff --git a/idhub/templates/credentials/verifiable_presentation.json b/idhub/templates/credentials/verifiable_presentation.json new file mode 100644 index 0000000..752affb --- /dev/null +++ b/idhub/templates/credentials/verifiable_presentation.json @@ -0,0 +1,11 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1" + ], + "id": "http://example.org/presentations/3731", + "type": [ + "VerifiablePresentation" + ], + "holder": "{{ holder_did }}", + "verifiableCredential": {{ verifiable_credential_list }} +} diff --git a/idhub/urls.py b/idhub/urls.py index 785f4d1..80dd103 100644 --- a/idhub/urls.py +++ b/idhub/urls.py @@ -20,6 +20,7 @@ from django.urls import path, reverse_lazy from .views import LoginView from .admin import views as views_admin from .user import views as views_user +from .verification_portal import views as views_verification_portal app_name = 'idhub' @@ -171,4 +172,7 @@ urlpatterns = [ name='admin_import'), path('admin/import/new', views_admin.ImportAddView.as_view(), name='admin_import_add'), + + path('verification_portal/verify/', views_verification_portal.verify, + name="verification_portal_verify") ] diff --git a/idhub/verification_portal/__init__.py b/idhub/verification_portal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/idhub/verification_portal/models.py b/idhub/verification_portal/models.py new file mode 100644 index 0000000..0bd203a --- /dev/null +++ b/idhub/verification_portal/models.py @@ -0,0 +1,24 @@ +from django.db import models + + +class VPVerifyRequest(models.Model): + """ + `nonce` is an opaque random string used to lookup verification requests. URL-safe. + Example: "UPBQ3JE2DGJYHP5CPSCRIGTHRTCYXMQPNQ" + `expected_credentials` is a JSON list of credential types that must be present in this VP. + Example: ["FinancialSituationCredential", "HomeConnectivitySurveyCredential"] + `expected_contents` is a JSON object that places optional constraints on the contents of the + returned VP. + Example: [{"FinancialSituationCredential": {"financial_vulnerability_score": "7"}}] + `action` is (for now) a JSON object describing the next steps to take if this verification + is successful. For example "send mail to with and " + Example: {"action": "send_mail", "params": {"to": "orders@somconnexio.coop", "subject": "New client", "body": ...} + `response` is a URL that the user's wallet will redirect the user to. + `submitted_on` is used (by a cronjob) to purge old entries that didn't complete verification + """ + nonce = models.CharField(max_length=50) + expected_credentials = models.CharField(max_length=255) + expected_contents = models.TextField() + action = models.TextField() + response_or_redirect = models.CharField(max_length=255) + submitted_on = models.DateTimeField(auto_now=True) diff --git a/idhub/verification_portal/views.py b/idhub/verification_portal/views.py new file mode 100644 index 0000000..486f4f7 --- /dev/null +++ b/idhub/verification_portal/views.py @@ -0,0 +1,49 @@ +import json + +from django.core.mail import send_mail +from django.http import HttpResponse, HttpResponseRedirect + +from utils.idhub_ssikit import verify_presentation +from .models import VPVerifyRequest +from django.shortcuts import get_object_or_404 +from more_itertools import flatten, unique_everseen + + +def verify(request): + assert request.method == "POST" + # TODO: incorporate request.POST["presentation_submission"] as schema definition + (presentation_valid, _) = verify_presentation(request.POST["vp_token"]) + if not presentation_valid: + raise Exception("Failed to verify signature on the given Verifiable Presentation.") + vp = json.loads(request.POST["vp_token"]) + nonce = vp["nonce"] + # "vr" = verification_request + vr = get_object_or_404(VPVerifyRequest, nonce=nonce) # TODO: return meaningful error, not 404 + # Get a list of all included verifiable credential types + included_credential_types = unique_everseen(flatten([ + vc["type"] for vc in vp["verifiableCredential"] + ])) + # Check that it matches what we requested + for requested_vc_type in json.loads(vr.expected_credentials): + if requested_vc_type not in included_credential_types: + raise Exception("You're missing some credentials we requested!") # TODO: return meaningful error + # Perform whatever action we have to do + action = json.loads(vr.action) + if action["action"] == "send_mail": + subject = action["params"]["subject"] + to_email = action["params"]["to"] + from_email = "noreply@verifier-portal" + body = request.POST["vp-token"] + send_mail( + subject, + body, + from_email, + [to_email] + ) + elif action["action"] == "something-else": + pass + else: + raise Exception("Unknown action!") + # OK! Your verifiable presentation was successfully presented. + return HttpResponseRedirect(vr.response_or_redirect) + diff --git a/requirements.txt b/requirements.txt index 745c483..83aea96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ didkit==0.3.2 jinja2==3.1.2 jsonref==1.1.0 pyld==2.0.3 +more-itertools==10.1.0 diff --git a/utils/idhub_ssikit/README.md b/utils/idhub_ssikit/README.md index e69de29..8443717 100644 --- a/utils/idhub_ssikit/README.md +++ b/utils/idhub_ssikit/README.md @@ -0,0 +1,73 @@ +# Helper routines to manage DIDs/VC/VPs + +This module is a wrapper around the functions exported by SpruceID's `DIDKit` framework. + +## DID generation and storage + +For now DIDs are of the kind `did:key`, with planned support for `did:web` in the near future. + +Creation of a DID involves two steps: +* Generate a unique DID controller key +* Derive a `did:key` type from the key + +Both must be stored in the IdHub database and linked to a `User` for later retrieval. + +```python +# Use case: generate and link a new DID for an existing user +user = request.user # ... + +controller_key = idhub_ssikit.generate_did_controller_key() +did_string = idhub_ssikit.keydid_from_controller_key(controller_key) + + +did = idhub.models.DID( + did = did_string, + user = user +) +did_controller_key = idhub.models.DIDControllerKey( + key_material = controller_key, + owner_did = did +) + +did.save() +did_controller_key.save() +``` + +## Verifiable Credential issuance + +Verifiable Credential templates are stored as Jinja2 (TBD) templates in `/schemas` folder. Please examine each template to see what data must be passed to it in order to render. + +The data passed to the template must at a minimum include: +* issuer_did +* subject_did +* vc_id + +For example, in order to render `/schemas/member-credential.json`: + +```python +from jinja2 import Environment, FileSystemLoader, select_autoescape +import idhub_ssikit + +env = Environment( + loader=FileSystemLoader("vc_templates"), + autoescape=select_autoescape() +) +unsigned_vc_template = env.get_template("member-credential.json") + +issuer_user = request.user +issuer_did = user.dids[0] # TODO: Django ORM pseudocode +issuer_did_controller_key = did.keys[0] # TODO: Django ORM pseudocode + +data = { + "vc_id": "http://pangea.org/credentials/3731", + "issuer_did": issuer_did, + "subject_did": "did:web:[...]", + "issuance_date": "2020-08-19T21:41:50Z", + "subject_is_member_of": "Pangea" +} +signed_credential = idhub_ssikit.render_and_sign_credential( + unsigned_vc_template, + issuer_did_controller_key, + data +) +``` \ No newline at end of file diff --git a/utils/idhub_ssikit/__init__.py b/utils/idhub_ssikit/__init__.py index 18a5ff2..c4ac0e3 100644 --- a/utils/idhub_ssikit/__init__.py +++ b/utils/idhub_ssikit/__init__.py @@ -72,3 +72,40 @@ def verify_credential(vc, proof_options): return didkit.verify_credential(vc, proof_options) return asyncio.run(inner()) + + +def issue_verifiable_presentation(vc_list: list[str], jwk_holder: str, holder_did: str) -> str: + async def inner(): + unsigned_vp = unsigned_vp_template.render(data) + signed_vp = await didkit.issue_presentation( + unsigned_vp, + '{"proofFormat": "ldp"}', + jwk_holder + ) + return signed_vp + + # TODO: convert from Jinja2 -> django-templates + env = Environment( + loader=FileSystemLoader("vc_templates"), + autoescape=select_autoescape() + ) + unsigned_vp_template = env.get_template("verifiable_presentation.json") + data = { + "holder_did": holder_did, + "verifiable_credential_list": "[" + ",".join(vc_list) + "]" + } + + return asyncio.run(inner()) + + +def verify_presentation(vp): + """ + Returns a (bool, str) tuple indicating whether the credential is valid. + If the boolean is true, the credential is valid and the second argument can be ignored. + If it is false, the VC is invalid and the second argument contains a JSON object with further information. + """ + async def inner(): + proof_options = '{"proofFormat": "ldp"}' + return didkit.verify_presentation(vp, proof_options) + + return asyncio.run(inner())