resolve super conflict

This commit is contained in:
Cayo Puigdefabregas 2024-01-18 15:09:19 +01:00
commit c76ed799be
25 changed files with 512 additions and 185 deletions

View File

@ -1,2 +1,2 @@
name surnames email typeOfPerson membershipType organisation affiliatedSince firstName lastName email membershipType membershipId affiliatedUntil affiliatedSince typeOfPerson identityDocType identityNumber
Pepe Gómez user1@example.org individual Member Pangea 01-01-2023 Pepe Gómez user1@example.org individual 123456 2024-01-01T00:00:00Z 2023-01-01T00:00:00Z natural DNI 12345678A

1 name firstName surnames lastName email membershipType membershipId organisation affiliatedUntil affiliatedSince typeOfPerson identityDocType identityNumber
2 Pepe Pepe Gómez Gómez user1@example.org Member individual 123456 Pangea 2024-01-01T00:00:00Z 01-01-2023 2023-01-01T00:00:00Z individual natural DNI 12345678A

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,13 @@
import csv import csv
import json import json
import base64 import base64
import copy
import pandas as pd import pandas as pd
from pyhanko.sign import signers from pyhanko.sign import signers
from django import forms from django import forms
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from utils import credtools, certs from utils import credtools, certs
@ -23,24 +25,39 @@ from idhub_auth.models import User
class ImportForm(forms.Form): class ImportForm(forms.Form):
did = forms.ChoiceField(label=_("Did"), choices=[]) did = forms.ChoiceField(label=_("Did"), choices=[])
eidas1 = forms.ChoiceField(
label=_("Signature with Eidas1"),
choices=[],
required=False
)
schema = forms.ChoiceField(label=_("Schema"), choices=[]) schema = forms.ChoiceField(label=_("Schema"), choices=[])
file_import = forms.FileField(label=_("File import")) file_import = forms.FileField(label=_("File import"))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self._schema = None self._schema = None
self._did = None self._did = None
self._eidas1 = None
self.rows = {} self.rows = {}
self.properties = {} self.properties = {}
self.user = kwargs.pop('user', None) self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
dids = DID.objects.filter(user=self.user)
self.fields['did'].choices = [ self.fields['did'].choices = [
(x.did, x.label) for x in DID.objects.filter(user=self.user) (x.did, x.label) for x in dids.filter(eidas1=False)
] ]
self.fields['schema'].choices = [ self.fields['schema'].choices = [
(x.id, x.name()) for x in Schemas.objects.filter() (x.id, x.name()) for x in Schemas.objects.filter()
] ]
if dids.filter(eidas1=True).exists():
choices = [("", "")]
choices.extend([
(x.did, x.label) for x in dids.filter(eidas1=True)
])
self.fields['eidas1'].choices = choices
else:
self.fields.pop('eidas1')
def clean_did(self): def clean(self):
data = self.cleaned_data["did"] data = self.cleaned_data["did"]
did = DID.objects.filter( did = DID.objects.filter(
user=self.user, user=self.user,
@ -52,6 +69,14 @@ class ImportForm(forms.Form):
self._did = did.first() self._did = did.first()
eidas1 = self.cleaned_data.get('eidas1')
if eidas1:
self._eidas1 = DID.objects.filter(
user=self.user,
eidas1=True,
did=eidas1
).first()
return data return data
def clean_schema(self): def clean_schema(self):
@ -65,7 +90,8 @@ class ImportForm(forms.Form):
self._schema = schema.first() self._schema = schema.first()
try: try:
self.json_schema = json.loads(self._schema.data) self.json_schema = json.loads(self._schema.data)
prop = self.json_schema['properties'] props = [x for x in self.json_schema["allOf"] if 'properties' in x.keys()]
prop = props[0]['properties']
self.properties = prop['credentialSubject']['properties'] self.properties = prop['credentialSubject']['properties']
except Exception: except Exception:
raise ValidationError("Schema is not valid!") raise ValidationError("Schema is not valid!")
@ -73,7 +99,10 @@ class ImportForm(forms.Form):
if not self.properties: if not self.properties:
raise ValidationError("Schema is not valid!") raise ValidationError("Schema is not valid!")
# TODO we need filter "$ref" of schema for can validate a csv
self.json_schema_filtered = copy.copy(self.json_schema)
allOf = [x for x in self.json_schema["allOf"] if '$ref' not in x.keys()]
self.json_schema_filtered["allOf"] = allOf
return data return data
def clean_file_import(self): def clean_file_import(self):
@ -115,7 +144,9 @@ class ImportForm(forms.Form):
def validate_jsonld(self, line, row): def validate_jsonld(self, line, row):
try: try:
credtools.validate_json(row, self.json_schema) check = credtools.validate_json(row, self.json_schema_filtered)
if check is not True:
raise ValidationError("Not valid row")
except Exception as e: except Exception as e:
msg = "line {}: {}".format(line+1, e) msg = "line {}: {}".format(line+1, e)
self.exception(msg) self.exception(msg)
@ -135,6 +166,7 @@ class ImportForm(forms.Form):
csv_data=json.dumps(row), csv_data=json.dumps(row),
issuer_did=self._did, issuer_did=self._did,
schema=self._schema, schema=self._schema,
eidas1_did=self._eidas1
) )
def exception(self, msg): def exception(self, msg):
@ -268,9 +300,13 @@ class ImportCertificateForm(forms.Form):
did=self.file_name, did=self.file_name,
label=self._label, label=self._label,
eidas1=True, eidas1=True,
user=self.user user=self.user,
type=DID.Types.KEY
) )
pw = cache.get("KEY_DIDS")
self._did.set_key_material(key_material, pw)
def save(self, commit=True): def save(self, commit=True):
if commit: if commit:

View File

@ -17,6 +17,7 @@ from django.views.generic.edit import (
UpdateView, UpdateView,
) )
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.core.cache import cache
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.http import HttpResponse from django.http import HttpResponse
from django.contrib import messages from django.contrib import messages
@ -640,19 +641,20 @@ class DidRegisterView(Credentials, CreateView):
icon = 'bi bi-patch-check-fill' icon = 'bi bi-patch-check-fill'
wallet = True wallet = True
model = DID model = DID
fields = ('label',) fields = ('label', 'type')
success_url = reverse_lazy('idhub:admin_dids') success_url = reverse_lazy('idhub:admin_dids')
object = None object = None
def form_valid(self, form): def form_valid(self, form):
form.instance.user = self.request.user form.instance.user = self.request.user
form.instance.set_did() form.instance.set_did(cache.get("KEY_DIDS"))
form.save() form.save()
messages.success(self.request, _('DID created successfully')) messages.success(self.request, _('DID created successfully'))
Event.set_EV_ORG_DID_CREATED_BY_ADMIN(form.instance) Event.set_EV_ORG_DID_CREATED_BY_ADMIN(form.instance)
return super().form_valid(form) return super().form_valid(form)
class DidEditView(Credentials, UpdateView): class DidEditView(Credentials, UpdateView):
template_name = "idhub/admin/did_register.html" template_name = "idhub/admin/did_register.html"
subtitle = _('Organization Identities (DID)') subtitle = _('Organization Identities (DID)')

View File

@ -7,6 +7,7 @@ from utils import credtools
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache
from decouple import config from decouple import config
from idhub.models import DID, Schemas from idhub.models import DID, Schemas
from oidc4vp.models import Organization from oidc4vp.models import Organization
@ -36,17 +37,25 @@ class Command(BaseCommand):
self.create_organizations(r[0].strip(), r[1].strip()) self.create_organizations(r[0].strip(), r[1].strip())
self.sync_credentials_organizations("pangea.org", "somconnexio.coop") self.sync_credentials_organizations("pangea.org", "somconnexio.coop")
self.sync_credentials_organizations("local 8000", "local 9000") self.sync_credentials_organizations("local 8000", "local 9000")
self.create_defaults_dids()
self.create_schemas() self.create_schemas()
def create_admin_users(self, email, password): def create_admin_users(self, email, password):
User.objects.create_superuser(email=email, password=password) su = User.objects.create_superuser(email=email, password=password)
su.set_encrypted_sensitive_data(password)
su.save()
key = su.decrypt_sensitive_data(password)
key_dids = {su.id: key}
cache.set("KEY_DIDS", key_dids, None)
self.create_defaults_dids(su, key)
def create_users(self, email, password): def create_users(self, email, password):
u= User.objects.create(email=email, password=password) u = User.objects.create(email=email, password=password)
u.set_password(password) u.set_password(password)
u.set_encrypted_sensitive_data(password)
u.save() u.save()
key = u.decrypt_sensitive_data(password)
self.create_defaults_dids(u, key)
def create_organizations(self, name, url): def create_organizations(self, name, url):
@ -61,12 +70,10 @@ class Command(BaseCommand):
org1.my_client_secret = org2.client_secret org1.my_client_secret = org2.client_secret
org1.save() org1.save()
org2.save() org2.save()
def create_defaults_dids(self, u, password):
def create_defaults_dids(self): did = DID(label="Default", user=u, type=DID.Types.KEY)
for u in User.objects.all(): did.set_did(password)
did = DID(label="Default", user=u) did.save()
did.set_did()
did.save()
def create_schemas(self): def create_schemas(self):
schemas_files = os.listdir(settings.SCHEMAS_DIR) schemas_files = os.listdir(settings.SCHEMAS_DIR)
@ -82,10 +89,18 @@ class Command(BaseCommand):
try: try:
ldata = json.loads(data) ldata = json.loads(data)
assert credtools.validate_schema(ldata) assert credtools.validate_schema(ldata)
name = ldata.get('name') dname = ldata.get('name')
assert name assert dname
except Exception: except Exception:
return return
name = ''
try:
for x in dname:
if settings.LANGUAGE_CODE in x['lang']:
name = x.get('value', '')
except Exception:
return
Schemas.objects.create(file_schema=file_name, data=data, type=name) Schemas.objects.create(file_schema=file_name, data=data, type=name)
def open_file(self, file_name): def open_file(self, file_name):

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2024-01-11 10:17 # Generated by Django 4.2.5 on 2024-01-18 11:32
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -25,11 +25,18 @@ class Migration(migrations.Migration):
verbose_name='ID', verbose_name='ID',
), ),
), ),
(
'type',
models.PositiveSmallIntegerField(
choices=[(1, 'Key'), (2, 'Web')], verbose_name='Type'
),
),
('created_at', models.DateTimeField(auto_now=True)), ('created_at', models.DateTimeField(auto_now=True)),
('label', models.CharField(max_length=50, verbose_name='Label')), ('label', models.CharField(max_length=50, verbose_name='Label')),
('did', models.CharField(max_length=250)), ('did', models.CharField(max_length=250)),
('key_material', models.TextField()), ('key_material', models.TextField()),
('eidas1', models.BooleanField(default=False)), ('eidas1', models.BooleanField(default=False)),
('didweb_document', models.TextField()),
( (
'user', 'user',
models.ForeignKey( models.ForeignKey(
@ -151,7 +158,6 @@ class Migration(migrations.Migration):
('issued_on', models.DateTimeField(null=True)), ('issued_on', models.DateTimeField(null=True)),
('data', models.TextField()), ('data', models.TextField()),
('csv_data', models.TextField()), ('csv_data', models.TextField()),
('public', models.BooleanField(default=True)),
('hash', models.CharField(max_length=260)), ('hash', models.CharField(max_length=260)),
( (
'status', 'status',
@ -165,6 +171,14 @@ class Migration(migrations.Migration):
default=1, default=1,
), ),
), ),
(
'eidas1_did',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to='idhub.did',
),
),
( (
'issuer_did', 'issuer_did',
models.ForeignKey( models.ForeignKey(

View File

@ -1,15 +1,21 @@
import json import json
import ujson
import pytz import pytz
import hashlib import hashlib
import datetime import datetime
from collections import OrderedDict
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from nacl import secret
from utils.idhub_ssikit import ( from utils.idhub_ssikit import (
generate_did_controller_key, generate_did_controller_key,
keydid_from_controller_key, keydid_from_controller_key,
sign_credential, sign_credential,
webdid_from_controller_key,
) )
from idhub_auth.models import User from idhub_auth.models import User
@ -404,6 +410,13 @@ class Event(models.Model):
class DID(models.Model): class DID(models.Model):
class Types(models.IntegerChoices):
KEY = 1, "Key"
WEB = 2, "Web"
type = models.PositiveSmallIntegerField(
_("Type"),
choices=Types.choices,
)
created_at = models.DateTimeField(auto_now=True) created_at = models.DateTimeField(auto_now=True)
label = models.CharField(_("Label"), max_length=50) label = models.CharField(_("Label"), max_length=50)
did = models.CharField(max_length=250) did = models.CharField(max_length=250)
@ -418,6 +431,13 @@ class DID(models.Model):
related_name='dids', related_name='dids',
null=True, null=True,
) )
didweb_document = models.TextField()
def get_key_material(self, password):
return self.user.decrypt_data(self.key_material, password)
def set_key_material(self, value, password):
self.key_material = self.user.encrypt_data(value, password)
@property @property
def is_organization_did(self): def is_organization_did(self):
@ -425,14 +445,20 @@ class DID(models.Model):
return True return True
return False return False
def set_did(self): def set_did(self, password):
self.key_material = generate_did_controller_key() new_key_material = generate_did_controller_key()
self.did = keydid_from_controller_key(self.key_material) self.set_key_material(new_key_material, password)
if self.type == self.Types.KEY:
self.did = keydid_from_controller_key(new_key_material)
elif self.type == self.Types.WEB:
didurl, document = webdid_from_controller_key(new_key_material)
self.did = didurl
self.didweb_document = document
def get_key(self): def get_key(self):
return json.loads(self.key_material) return json.loads(self.key_material)
class Schemas(models.Model): class Schemas(models.Model):
type = models.CharField(max_length=250) type = models.CharField(max_length=250)
file_schema = models.CharField(max_length=250) file_schema = models.CharField(max_length=250)
@ -445,8 +471,18 @@ class Schemas(models.Model):
return {} return {}
return json.loads(self.data) return json.loads(self.data)
def name(self): def name(self, request=None):
return self.get_schema.get('name', '') names = {}
for name in self.get_schema.get('name', []):
lang = name.get('lang')
if 'ca' in lang:
lang = 'ca'
names[lang]= name.get('value')
if request and request.LANGUAGE_CODE in names.keys():
return names[request.LANGUAGE_CODE]
return names[settings.LANGUAGE_CODE]
def description(self): def description(self):
return self.get_schema.get('description', '') return self.get_schema.get('description', '')
@ -468,7 +504,6 @@ class VerificableCredential(models.Model):
issued_on = models.DateTimeField(null=True) issued_on = models.DateTimeField(null=True)
data = models.TextField() data = models.TextField()
csv_data = models.TextField() csv_data = models.TextField()
public = models.BooleanField(default=settings.DEFAULT_PUBLIC_CREDENTIALS)
hash = models.CharField(max_length=260) hash = models.CharField(max_length=260)
status = models.PositiveSmallIntegerField( status = models.PositiveSmallIntegerField(
choices=Status.choices, choices=Status.choices,
@ -490,21 +525,50 @@ class VerificableCredential(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='vcredentials', related_name='vcredentials',
) )
eidas1_did = models.ForeignKey(
DID,
on_delete=models.CASCADE,
null=True
)
schema = models.ForeignKey( schema = models.ForeignKey(
Schemas, Schemas,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='vcredentials', related_name='vcredentials',
) )
def get_data(self, password):
if not self.data:
return ""
if self.eidas1_did:
return self.data
return self.user.decrypt_data(self.data, password)
def set_data(self, value, password):
self.data = self.user.encrypt_data(value, password)
def type(self): def type(self):
return self.schema.type return self.schema.type
def description(self): def description(self):
for des in json.loads(self.render()).get('description', []): for des in json.loads(self.render("")).get('description', []):
if settings.LANGUAGE_CODE == des.get('lang'): if settings.LANGUAGE_CODE in des.get('lang'):
return des.get('value', '') return des.get('value', '')
return '' return ''
def get_type(self, lang=None):
schema = json.loads(self.schema.data)
if not schema.get('name'):
return ''
try:
for x in schema['name']:
if lang or settings.LANGUAGE_CODE in x['lang']:
return x.get('value', '')
except:
return self.schema.type
return ''
def get_status(self): def get_status(self):
return self.Status(self.status).label return self.Status(self.status).label
@ -512,21 +576,29 @@ class VerificableCredential(models.Model):
data = json.loads(self.csv_data).items() data = json.loads(self.csv_data).items()
return data return data
def issue(self, did): def issue(self, did, password, domain=settings.DOMAIN.strip("/")):
if self.status == self.Status.ISSUED: if self.status == self.Status.ISSUED:
return return
self.status = self.Status.ISSUED self.status = self.Status.ISSUED
self.subject_did = did self.subject_did = did
self.issued_on = datetime.datetime.now().astimezone(pytz.utc) self.issued_on = datetime.datetime.now().astimezone(pytz.utc)
self.data = sign_credential( issuer_pass = cache.get("KEY_DIDS")
self.render(), # issuer_pass = self.user.decrypt_data(
self.issuer_did.key_material # cache.get("KEY_DIDS"),
# settings.SECRET_KEY,
# )
data = sign_credential(
self.render(domain),
self.issuer_did.get_key_material(issuer_pass)
) )
if self.public: if self.eidas1_did:
self.data = data
self.hash = hashlib.sha3_256(self.data.encode()).hexdigest() self.hash = hashlib.sha3_256(self.data.encode()).hexdigest()
else:
self.data = self.user.encrypt_data(data, password)
def get_context(self): def get_context(self, domain):
d = json.loads(self.csv_data) d = json.loads(self.csv_data)
issuance_date = '' issuance_date = ''
if self.issued_on: if self.issued_on:
@ -534,31 +606,38 @@ class VerificableCredential(models.Model):
issuance_date = self.issued_on.strftime(format) issuance_date = self.issued_on.strftime(format)
cred_path = 'credentials' cred_path = 'credentials'
if self.public: if self.eidas1_did:
cred_path = 'public/credentials' cred_path = 'public/credentials'
url_id = "{}/{}/{}".format( url_id = "{}/{}/{}".format(
settings.DOMAIN.strip("/"), domain,
cred_path, cred_path,
self.id self.id
) )
context = { context = {
'vc_id': url_id, 'vc_id': url_id,
'issuer_did': self.issuer_did.did, 'issuer_did': self.issuer_did.did,
'subject_did': self.subject_did and self.subject_did.did or '', 'subject_did': self.subject_did and self.subject_did.did or '',
'issuance_date': issuance_date, 'issuance_date': issuance_date,
'first_name': self.user.first_name, 'firstName': self.user.first_name or "",
'last_name': self.user.last_name, 'lastName': self.user.last_name or "",
'email': self.user.email,
'organisation': settings.ORGANIZATION or '',
} }
context.update(d) context.update(d)
context['firstName'] = ""
return context return context
def render(self): def render(self, domain):
context = self.get_context() context = self.get_context(domain)
template_name = 'credentials/{}'.format( template_name = 'credentials/{}'.format(
self.schema.file_schema self.schema.file_schema
) )
tmpl = get_template(template_name) tmpl = get_template(template_name)
return tmpl.render(context) d_ordered = ujson.loads(tmpl.render(context))
d_minimum = self.filter_dict(d_ordered)
return ujson.dumps(d_minimum)
def get_issued_on(self): def get_issued_on(self):
@ -567,6 +646,18 @@ class VerificableCredential(models.Model):
return '' return ''
def filter_dict(self, dic):
new_dict = OrderedDict()
for key, value in dic.items():
if isinstance(value, dict):
new_value = self.filter_dict(value)
if new_value:
new_dict[key] = new_value
elif value:
new_dict[key] = value
return new_dict
class VCTemplate(models.Model): class VCTemplate(models.Model):
wkit_template_id = models.CharField(max_length=250) wkit_template_id = models.CharField(max_length=250)
data = models.TextField() data = models.TextField()

View File

@ -1,31 +1,8 @@
{ {
"@context": [ "@context": [
"https://www.w3.org/2018/credentials/v1", "https://www.w3.org/2018/credentials/v1",
{ "https://idhub.pangea.org/credentials/base/v1",
"individual": "https://schema.org/Person", "https://idhub.pangea.org/credentials/membership-card/v1"
"Member": "https://schema.org/Member",
"startDate": "https://schema.org/startDate",
"jsonSchema": "https://schema.org/jsonSchema",
"$ref": "https://schema.org/jsonSchemaRef",
"credentialSchema": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#credentialSchema",
"organisation": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#organisation",
"membershipType": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#membershipType",
"membershipId": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#membershipId",
"typeOfPerson": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#typeOfPerson",
"identityDocType": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#identityDocType",
"identityNumber": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#identityNumber",
"name": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#name",
"description": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#description",
"value": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#value",
"lang": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#lang",
"surnames": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#surnames",
"email": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#email",
"affiliatedSince": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#affiliatedSince",
"affiliatedUntil": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#affiliatedUntil",
"issued": "https://ec.europa.eu/digital-building-blocks/wikis/display/EBSIDOC/Verifiable+Attestation#issued",
"validFrom": "https://ec.europa.eu/digital-building-blocks/wikis/display/EBSIDOC/Verifiable+Attestation#validFrom",
"validUntil": "https://ec.europa.eu/digital-building-blocks/wikis/display/EBSIDOC/Verifiable+Attestation#validUntil"
}
], ],
"type": [ "type": [
"VerifiableCredential", "VerifiableCredential",
@ -35,22 +12,7 @@
"id": "{{ vc_id }}", "id": "{{ vc_id }}",
"issuer": { "issuer": {
"id": "{{ issuer_did }}", "id": "{{ issuer_did }}",
"name": "Pangea", "name": "{{ organisation }}"
"description": [
{
"value": "Pangea.org is a service provider leveraging open-source technologies to provide affordable and accessible solutions for social enterprises and solidarity organisations.",
"lang": "en"
},
{
"value": "Pangea.org és un proveïdor de serveis que aprofita les tecnologies de codi obert per oferir solucions assequibles i accessibles per a empreses socials i organitzacions solidàries.",
"lang": "ca_ES"
},
{
"value": "Pangea.org es un proveedor de servicios que aprovecha tecnologías de código abierto para proporcionar soluciones asequibles y accesibles para empresas sociales y organizaciones solidarias.",
"lang": "es"
}
]
}, },
"issuanceDate": "{{ issuance_date }}", "issuanceDate": "{{ issuance_date }}",
"issued": "{{ issuance_date }}", "issued": "{{ issuance_date }}",
@ -86,20 +48,20 @@
], ],
"credentialSubject": { "credentialSubject": {
"id": "{{ subject_did }}", "id": "{{ subject_did }}",
"organisation": "Pangea", "firstName": "{{ firstName }}",
"membershipType": "{{ membershipType }}", "lastName": "{{ lastName }}",
"membershipId": "{{ vc_id }}", "email": "{{ email }}",
"affiliatedSince": "{{ affiliatedSince }}",
"affiliatedUntil": "{{ affiliatedUntil }}",
"typeOfPerson": "{{ typeOfPerson }}", "typeOfPerson": "{{ typeOfPerson }}",
"identityDocType": "{{ identityDocType }}", "identityDocType": "{{ identityDocType }}",
"identityNumber": "{{ identityNumber }}", "identityNumber": "{{ identityNumber }}",
"name": "{{ first_name }}", "organisation": "{{ organisation }}",
"surnames": "{{ last_name }}", "membershipType": "{{ membershipType }}",
"email": "{{ email }}", "membershipId": "{{ vc_id }}",
"credentialSchema": { "affiliatedSince": "{{ affiliatedSince }}",
"id": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/membership-card-schema.json", "affiliatedUntil": "{{ affiliatedUntil }}"
"type": "JsonSchema" },
} "credentialSchema": {
"id": "https://idhub.pangea.org/vc_schemas/membership-card.json",
"type": "FullJsonSchemaValidator2021"
} }
} }

View File

@ -23,7 +23,7 @@
<tbody> <tbody>
{% for f in credentials.all %} {% for f in credentials.all %}
<tr style="font-size:15px;"> <tr style="font-size:15px;">
<td>{{ f.type }}</td> <td>{{ f.get_type }}</td>
<td>{{ f.description }}</td> <td>{{ f.description }}</td>
<td>{{ f.get_issued_on }}</td> <td>{{ f.get_issued_on }}</td>
<td class="text-center">{{ f.get_status }}</td> <td class="text-center">{{ f.get_status }}</td>

View File

@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<div class="row mt-3"> <div class="row mt-3">
{% if object.public %} {% if object.eidas1_did %}
<div class="col text-center"> <div class="col text-center">
<a class="btn btn-green-user" href="{% url 'idhub:user_credential_pdf' object.id %}">{% trans 'Sign credential in PDF format' %}</a> <a class="btn btn-green-user" href="{% url 'idhub:user_credential_pdf' object.id %}">{% trans 'Sign credential in PDF format' %}</a>
</div> </div>

View File

@ -22,7 +22,7 @@
<tbody> <tbody>
{% for f in credentials.all %} {% for f in credentials.all %}
<tr style="font-size:15px;"> <tr style="font-size:15px;">
<td>{{ f.type }}</td> <td>{{ f.get_type }}</td>
<td>{{ f.description }}</td> <td>{{ f.description }}</td>
<td>{{ f.get_issued_on }}</td> <td>{{ f.get_issued_on }}</td>
<td class="text-center">{{ f.get_status }}</td> <td class="text-center">{{ f.get_status }}</td>

View File

@ -17,7 +17,7 @@ Including another URLconf
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.views.generic import RedirectView from django.views.generic import RedirectView
from django.urls import path, reverse_lazy from django.urls import path, reverse_lazy
from .views import LoginView from .views import LoginView, PasswordResetConfirmView, serve_did
from .admin import views as views_admin from .admin import views as views_admin
from .user import views as views_user from .user import views as views_user
# from .verification_portal import views as views_verification_portal # from .verification_portal import views as views_verification_portal
@ -45,13 +45,16 @@ urlpatterns = [
), ),
name='password_reset_done' name='password_reset_done'
), ),
path('auth/reset/<uidb64>/<token>/', path('auth/reset/<uidb64>/<token>/', PasswordResetConfirmView.as_view(),
auth_views.PasswordResetConfirmView.as_view(
template_name='auth/password_reset_confirm.html',
success_url=reverse_lazy('idhub:password_reset_complete')
),
name='password_reset_confirm' name='password_reset_confirm'
), ),
# path('auth/reset/<uidb64>/<token>/',
# auth_views.PasswordResetConfirmView.as_view(
# template_name='auth/password_reset_confirm.html',
# success_url=reverse_lazy('idhub:password_reset_complete')
# ),
# name='password_reset_confirm'
# ),
path('auth/reset/done/', path('auth/reset/done/',
auth_views.PasswordResetCompleteView.as_view( auth_views.PasswordResetCompleteView.as_view(
template_name='auth/password_reset_complete.html' template_name='auth/password_reset_complete.html'
@ -177,6 +180,8 @@ urlpatterns = [
path('admin/import/new', views_admin.ImportAddView.as_view(), path('admin/import/new', views_admin.ImportAddView.as_view(),
name='admin_import_add'), name='admin_import_add'),
path('did-registry/<str:did_id>/did.json', serve_did)
# path('verification_portal/verify/', views_verification_portal.verify, # path('verification_portal/verify/', views_verification_portal.verify,
# name="verification_portal_verify") # name="verification_portal_verify")
] ]

View File

@ -22,12 +22,15 @@ class RequestCredentialForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None) self.user = kwargs.pop('user', None)
self.lang = kwargs.pop('lang', None)
self._domain = kwargs.pop('domain', None)
self.password = kwargs.pop('password', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['did'].choices = [ self.fields['did'].choices = [
(x.did, x.label) for x in DID.objects.filter(user=self.user) (x.did, x.label) for x in DID.objects.filter(user=self.user)
] ]
self.fields['credential'].choices = [ self.fields['credential'].choices = [
(x.id, x.type()) for x in VerificableCredential.objects.filter( (x.id, x.get_type(lang=self.lang)) for x in VerificableCredential.objects.filter(
user=self.user, user=self.user,
status=VerificableCredential.Status.ENABLED status=VerificableCredential.Status.ENABLED
) )
@ -49,7 +52,8 @@ class RequestCredentialForm(forms.Form):
did = did[0] did = did[0]
cred = cred[0] cred = cred[0]
try: try:
cred.issue(did) if self.password:
cred.issue(did, self.password, domain=self._domain)
except Exception: except Exception:
return return

View File

@ -25,6 +25,7 @@ from django.views.generic.base import TemplateView
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.http import HttpResponse from django.http import HttpResponse
from django.core.cache import cache
from django.contrib import messages from django.contrib import messages
from django.conf import settings from django.conf import settings
from idhub.user.forms import ( from idhub.user.forms import (
@ -140,7 +141,7 @@ class CredentialPdfView(MyWallet, TemplateView):
self.object = get_object_or_404( self.object = get_object_or_404(
VerificableCredential, VerificableCredential,
pk=pk, pk=pk,
public=True, eidas1_did__isnull=False,
user=self.request.user user=self.request.user
) )
self.url_id = "{}://{}/public/credentials/{}".format( self.url_id = "{}://{}/public/credentials/{}".format(
@ -150,7 +151,7 @@ class CredentialPdfView(MyWallet, TemplateView):
) )
data = self.build_certificate() data = self.build_certificate()
if DID.objects.filter(eidas1=True).exists(): if self.object.eidas1_did:
doc = self.insert_signature(data) doc = self.insert_signature(data)
else: else:
doc = data doc = data
@ -221,10 +222,11 @@ class CredentialPdfView(MyWallet, TemplateView):
return base64.b64encode(img_buffer.getvalue()).decode('utf-8') return base64.b64encode(img_buffer.getvalue()).decode('utf-8')
def get_pfx_data(self): def get_pfx_data(self):
did = DID.objects.filter(eidas1=True).first() did = self.object.eidas1_did
if not did: if not did:
return None, None return None, None
key_material = json.loads(did.key_material) pw = cache.get("KEY_DIDS")
key_material = json.loads(did.get_key_material(pw))
cert = key_material.get("cert") cert = key_material.get("cert")
passphrase = key_material.get("passphrase") passphrase = key_material.get("passphrase")
if cert and passphrase: if cert and passphrase:
@ -274,7 +276,15 @@ class CredentialJsonView(MyWallet, TemplateView):
pk=pk, pk=pk,
user=self.request.user user=self.request.user
) )
response = HttpResponse(self.object.data, content_type="application/json") pass_enc = self.request.session.get("key_did")
data = ""
if pass_enc:
user_pass = self.request.user.decrypt_data(
pass_enc,
self.request.user.password+self.request.session._session_key
)
data = self.object.get_data(user_pass)
response = HttpResponse(data, content_type="application/json")
response['Content-Disposition'] = 'attachment; filename={}'.format("credential.json") response['Content-Disposition'] = 'attachment; filename={}'.format("credential.json")
return response return response
@ -286,6 +296,7 @@ class PublicCredentialJsonView(View):
self.object = get_object_or_404( self.object = get_object_or_404(
VerificableCredential, VerificableCredential,
hash=pk, hash=pk,
eidas1_did__isnull=False,
) )
response = HttpResponse(self.object.data, content_type="application/json") response = HttpResponse(self.object.data, content_type="application/json")
response['Content-Disposition'] = 'attachment; filename={}'.format("credential.json") response['Content-Disposition'] = 'attachment; filename={}'.format("credential.json")
@ -302,6 +313,18 @@ class CredentialsRequestView(MyWallet, FormView):
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user kwargs['user'] = self.request.user
kwargs['lang'] = self.request.LANGUAGE_CODE
domain = "{}://{}".format(self.request.scheme, self.request.get_host())
kwargs['domain'] = domain
pass_enc = self.request.session.get("key_did")
if pass_enc:
user_pass = self.request.user.decrypt_data(
pass_enc,
self.request.user.password+self.request.session._session_key
)
else:
pass_enc = None
kwargs['password'] = user_pass
return kwargs return kwargs
def form_valid(self, form): def form_valid(self, form):
@ -366,13 +389,17 @@ class DidRegisterView(MyWallet, CreateView):
icon = 'bi bi-patch-check-fill' icon = 'bi bi-patch-check-fill'
wallet = True wallet = True
model = DID model = DID
fields = ('label',) fields = ('label', 'type')
success_url = reverse_lazy('idhub:user_dids') success_url = reverse_lazy('idhub:user_dids')
object = None object = None
def form_valid(self, form): def form_valid(self, form):
form.instance.user = self.request.user form.instance.user = self.request.user
form.instance.set_did() pw = self.request.user.decrypt_data(
self.request.session.get("key_did"),
self.request.user.password+self.request.session._session_key
)
form.instance.set_did(pw)
form.save() form.save()
messages.success(self.request, _('DID created successfully')) messages.success(self.request, _('DID created successfully'))

View File

@ -1,8 +1,14 @@
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.contrib.auth import login as auth_login from django.contrib.auth import login as auth_login
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect, HttpResponse
from idhub.models import DID
from trustchain_idhub import settings
class LoginView(auth_views.LoginView): class LoginView(auth_views.LoginView):
@ -21,8 +27,45 @@ class LoginView(auth_views.LoginView):
def form_valid(self, form): def form_valid(self, form):
user = form.get_user() user = form.get_user()
password = form.cleaned_data.get("password")
auth_login(self.request, user)
sensitive_data_encryption_key = user.decrypt_sensitive_data(password)
if not user.is_anonymous and user.is_admin: if not user.is_anonymous and user.is_admin:
admin_dashboard = reverse_lazy('idhub:admin_dashboard') admin_dashboard = reverse_lazy('idhub:admin_dashboard')
self.extra_context['success_url'] = admin_dashboard self.extra_context['success_url'] = admin_dashboard
auth_login(self.request, user) # encryption_key = user.encrypt_data(
# sensitive_data_encryption_key,
# settings.SECRET_KEY
# )
# cache.set("KEY_DIDS", encryption_key, None)
cache.set("KEY_DIDS", sensitive_data_encryption_key, None)
self.request.session["key_did"] = user.encrypt_data(
sensitive_data_encryption_key,
user.password+self.request.session._session_key
)
return HttpResponseRedirect(self.extra_context['success_url']) return HttpResponseRedirect(self.extra_context['success_url'])
class PasswordResetConfirmView(auth_views.PasswordResetConfirmView):
template_name = 'auth/password_reset_confirm.html'
success_url = reverse_lazy('idhub:password_reset_complete')
def form_valid(self, form):
password = form.cleaned_data.get("password")
user = form.get_user()
user.set_encrypted_sensitive_data(password)
user.save()
return HttpResponseRedirect(self.success_url)
def serve_did(request, did_id):
id_did = f'did:web:{settings.DOMAIN}:did-registry:{did_id}'
did = get_object_or_404(DID, did=id_did)
document = did.didweb_document
retval = HttpResponse(document)
retval.headers["Content-Type"] = "application/json"
return retval

View File

@ -31,4 +31,3 @@ class ProfileForm(forms.ModelForm):
return last_name return last_name

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2024-01-11 10:17 # Generated by Django 4.2.5 on 2024-01-18 11:32
from django.db import migrations, models from django.db import migrations, models
@ -48,6 +48,8 @@ class Migration(migrations.Migration):
blank=True, max_length=255, null=True, verbose_name='Last name' blank=True, max_length=255, null=True, verbose_name='Last name'
), ),
), ),
('encrypted_sensitive_data', models.CharField(max_length=255)),
('salt', models.CharField(max_length=255)),
], ],
options={ options={
'abstract': False, 'abstract': False,

View File

@ -1,4 +1,9 @@
import nacl
import base64
from nacl import pwhash
from django.db import models from django.db import models
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import BaseUserManager, AbstractBaseUser from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
@ -44,6 +49,8 @@ class User(AbstractBaseUser):
is_admin = models.BooleanField(default=False) is_admin = models.BooleanField(default=False)
first_name = models.CharField(_("First name"), max_length=255, blank=True, null=True) first_name = models.CharField(_("First name"), max_length=255, blank=True, null=True)
last_name = models.CharField(_("Last name"), max_length=255, blank=True, null=True) last_name = models.CharField(_("Last name"), max_length=255, blank=True, null=True)
encrypted_sensitive_data = models.CharField(max_length=255)
salt = models.CharField(max_length=255)
objects = UserManager() objects = UserManager()
@ -86,3 +93,65 @@ class User(AbstractBaseUser):
for r in s.service.rol.all(): for r in s.service.rol.all():
roles.append(r.name) roles.append(r.name)
return ", ".join(set(roles)) return ", ".join(set(roles))
def derive_key_from_password(self, password):
kdf = pwhash.argon2i.kdf
ops = pwhash.argon2i.OPSLIMIT_INTERACTIVE
mem = pwhash.argon2i.MEMLIMIT_INTERACTIVE
return kdf(
nacl.secret.SecretBox.KEY_SIZE,
password,
self.get_salt(),
opslimit=ops,
memlimit=mem
)
def decrypt_sensitive_data(self, password, data=None):
sb_key = self.derive_key_from_password(password.encode('utf-8'))
sb = nacl.secret.SecretBox(sb_key)
if not data:
data = self.get_encrypted_sensitive_data()
if not isinstance(data, bytes):
data = data.encode('utf-8')
return sb.decrypt(data).decode('utf-8')
def encrypt_sensitive_data(self, password, data):
sb_key = self.derive_key_from_password(password.encode('utf-8'))
sb = nacl.secret.SecretBox(sb_key)
if not isinstance(data, bytes):
data = data.encode('utf-8')
return base64.b64encode(sb.encrypt(data)).decode('utf-8')
def get_salt(self):
return base64.b64decode(self.salt.encode('utf-8'))
def set_salt(self):
self.salt = base64.b64encode(nacl.utils.random(16)).decode('utf-8')
def get_encrypted_sensitive_data(self):
return base64.b64decode(self.encrypted_sensitive_data.encode('utf-8'))
def set_encrypted_sensitive_data(self, password):
key = base64.b64encode(nacl.utils.random(64))
self.set_salt()
key_crypted = self.encrypt_sensitive_data(password, key)
self.encrypted_sensitive_data = key_crypted
def encrypt_data(self, data, password):
sb = self.get_secret_box(password)
value_enc = sb.encrypt(data.encode('utf-8'))
return base64.b64encode(value_enc).decode('utf-8')
def decrypt_data(self, data, password):
# import pdb; pdb.set_trace()
sb = self.get_secret_box(password)
value = base64.b64decode(data.encode('utf-8'))
return sb.decrypt(value).decode('utf-8')
def get_secret_box(self, password):
pw = base64.b64decode(password.encode('utf-8')*4)
sb_key = self.derive_key_from_password(pw)
return nacl.secret.SecretBox(sb_key)

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2024-01-11 10:17 # Generated by Django 4.2.5 on 2024-01-18 11:32
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2024-01-11 10:17 # Generated by Django 4.2.5 on 2024-01-18 11:32
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion

View File

@ -9,10 +9,10 @@ pandas==2.1.1
xlrd==2.0.1 xlrd==2.0.1
odfpy==1.4.1 odfpy==1.4.1
requests==2.31.0 requests==2.31.0
didkit==0.3.2
jinja2==3.1.2 jinja2==3.1.2
jsonref==1.1.0 jsonref==1.1.0
pyld==2.0.3 pyld==2.0.3
pynacl==1.5.0
more-itertools==10.1.0 more-itertools==10.1.0
dj-database-url==2.1.0 dj-database-url==2.1.0
PyPDF2 PyPDF2
@ -25,3 +25,5 @@ qrcode
uharfbuzz==0.38.0 uharfbuzz==0.38.0
fontTools==4.47.0 fontTools==4.47.0
weasyprint==60.2 weasyprint==60.2
ujson==5.9.0
didkit-0.3.2-cp311-cp311-manylinux_2_34_x86_64.whl

View File

@ -1,65 +1,94 @@
{ {
"$id": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/membership-card-schema.json", "$id": "https://idhub.pangea.org/vc_schemas/membership-card.json",
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"name": "MembershipCard", "title": "Membership Card",
"description": "MembershipCard credential using JsonSchema", "description": "The membership card specifies an individual's subscription or enrollment in specific services or benefits issued by an organization.",
"type": "object", "name": [
"properties": { {
"credentialSubject": { "value": "Membership Card",
"type": "object", "lang": "en"
"properties": { },
"organisation": { {
"type": "string" "value": "Carnet de soci/a",
}, "lang": "ca_ES"
"membershipType": { },
"type": "string" {
}, "value": "Carnet de socio/a",
"affiliatedSince": { "lang": "es"
"type": "string",
"format": "date-time"
},
"affiliatedUntil": {
"type": "string",
"format": "date-time"
},
"typeOfPerson": {
"type": "string",
"enum": [
"individual",
"org"
]
},
"identityDocType": {
"type": "string",
"enum": [
"DNI",
"NIF",
"NIE",
"PASSPORT"
]
},
"identityNumber": {
"type": "string"
},
"name": {
"type": "string"
},
"surnames": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
}
},
"required": [
"organisation",
"affiliatedSince",
"typeOfPerson",
"name",
"surnames",
"email"
]
} }
} ],
"type": "object",
"allOf": [
{
"$ref": "https://idhub.pangea.org/vc_schemas/ebsi/attestation.json"
},
{
"properties": {
"credentialSubject": {
"description": "Defines additional properties on credentialSubject",
"type": "object",
"properties": {
"id": {
"description": "Defines a unique identifier of the credential subject",
"type": "string"
},
"organisation": {
"description": "Organisation the credential subject is affiliated with",
"type": "string"
},
"membershipType": {
"description": "Type of membership",
"type": "string"
},
"membershipId": {
"description": "Membership identifier",
"type": "string"
},
"affiliatedSince": {
"type": "string",
"format": "date-time"
},
"affiliatedUntil": {
"type": "string",
"format": "date-time"
},
"typeOfPerson": {
"type": "string",
"enum": [
"natural",
"legal"
]
},
"identityDocType": {
"description": "Type of the Identity Document of the credential subject",
"type": "string"
},
"identityNumber": {
"description": "Number of the Identity Document of the credential subject",
"type": "string"
},
"firstName": {
"description": "Name of the natural person or name of the legal person (organisation)",
"type": "string"
},
"lastName": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
}
},
"required": [
"id",
"organisation",
"affiliatedSince",
"typeOfPerson",
"firstName",
"email"
]
}
}
}
]
} }

View File

@ -149,6 +149,7 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/ # https://docs.djangoproject.com/en/4.2/topics/i18n/
@ -222,4 +223,4 @@ LOGGING = {
} }
} }
DEFAULT_PUBLIC_CREDENTIALS = True ORGANIZATION = config('ORGANIZATION', 'Pangea')

View File

@ -6,6 +6,8 @@ import jinja2
from django.template.backends.django import Template from django.template.backends.django import Template
from django.template.loader import get_template from django.template.loader import get_template
from trustchain_idhub import settings
def generate_did_controller_key(): def generate_did_controller_key():
return didkit.generate_ed25519_key() return didkit.generate_ed25519_key()
@ -15,6 +17,30 @@ def keydid_from_controller_key(key):
return didkit.key_to_did("key", key) return didkit.key_to_did("key", key)
async def resolve_keydid(keydid):
return await didkit.resolve_did(keydid, "{}")
def webdid_from_controller_key(key):
"""
Se siguen los pasos para generar un webdid a partir de un keydid.
Documentado en la docu de spruceid.
"""
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"
webdid_url = f"did:web:{settings.DOMAIN}:did-registry:{pubkeyid}" # nueva URL: "did:web:idhub.pangea.org:<...>"
webdid_url_owner = webdid_url + "#owner"
# Reemplazamos los campos del documento DID necesarios:
document["id"] = webdid_url
document["verificationMethod"][0]["id"] = webdid_url_owner
document["verificationMethod"][0]["controller"] = webdid_url
document["authentication"][0] = webdid_url_owner
document["assertionMethod"][0] = webdid_url_owner
document_fixed_serialized = json.dumps(document)
return webdid_url, document_fixed_serialized
def generate_generic_vc_id(): def generate_generic_vc_id():
# TODO agree on a system for Verifiable Credential IDs # TODO agree on a system for Verifiable Credential IDs
return "https://pangea.org/credentials/42" return "https://pangea.org/credentials/42"