From 8c6cf5417f22699d3d2b9277bb95224fa1f6a23b Mon Sep 17 00:00:00 2001 From: PatStLouis Date: Tue, 26 Mar 2024 05:44:38 +0000 Subject: [PATCH 1/3] refactor controllers Signed-off-by: PatStLouis --- .env.example | 19 ++ docker-compose.yml | 69 +++++ traceability-controller/Dockerfile | 17 ++ .../app/controllers/askar.py | 108 +++----- .../app/controllers/auth.py | 44 +-- .../app/controllers/did_document.py | 50 ++++ .../app/controllers/status_list.py | 256 ++++++++---------- .../app/controllers/traction.py | 243 +++++++++++++++++ .../app/models/did_document.py | 56 ++-- .../app/routers/authentication.py | 2 +- .../app/routers/credentials.py | 75 ++--- .../app/routers/identifiers.py | 48 ++-- traceability-controller/app/utils.py | 16 ++ traceability-controller/config.py | 8 +- traceability-controller/main.py | 7 +- 15 files changed, 673 insertions(+), 345 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml create mode 100644 traceability-controller/Dockerfile create mode 100644 traceability-controller/app/controllers/did_document.py create mode 100644 traceability-controller/app/controllers/traction.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f371a1d --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Traceability Controller +TRACEABILITY_CONTROLLER_DOMAIN='' + +# Traction +TRACTION_API_URL='https://traction-sandbox-tenant-proxy.apps.silver.devops.gov.bc.ca' +TRACTION_TENANT_ID='' +TRACTION_API_KEY='' + +# Temporary verifier agent endpoint +# until next release (Aca-py admin v0.11) +VERIFIER_ENDPOINT='http://verifier-agent:8020' + +# Postgres +POSTGRES_URI='postgres://postgres:postgres@postgres:5432' +POSTGRES_USER='postgres' +POSTGRES_PASSWORD='postgres' + +# Letsencrypt +LETSENCRYPT_EMAIL='' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3630472 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +--- +version: '3' +services: + + agent: + image: bcgovimages/aries-cloudagent:py3.9-indy-1.16.0_0.12.0rc2 + ports: + - 8020:8020 + entrypoint: ["aca-py", "start"] + command: [ + '--no-ledger', + '--admin', '0.0.0.0', '8020', + '--admin-insecure-mode', + '--endpoint', 'http://agent:8021', + '--outbound-transport', 'http', + '--inbound-transport', 'http', '0.0.0.0', '8021' + ] + + postgres: + image: postgres:16-alpine + ports: + - 5432:5432 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + traceability-api: + build: + context: ./traceability-controller + ports: + - 8000:8000 + entrypoint: ["python", "main.py"] + environment: + POSTGRES_URI: ${POSTGRES_URI} + TRACTION_API_KEY: ${TRACTION_API_KEY} + TRACTION_TENANT_ID: ${TRACTION_TENANT_ID} + TRACTION_API_ENDPOINT: ${TRACTION_API_ENDPOINT} + VERIFIER_ENDPOINT: ${VERIFIER_ENDPOINT} + TRACEABILITY_CONTROLLER_DOMAIN: ${TRACEABILITY_CONTROLLER_DOMAIN} + labels: + - traefik.enable=true + - traefik.http.routers.traceability-api.rule=Host(`${TRACEABILITY_CONTROLLER_DOMAIN}`) + - traefik.http.routers.traceability-api.entrypoints=websecure + - traefik.http.routers.traceability-api.tls.certresolver=myresolver + - traefik.http.services.traceability-api.loadbalancer.server.port=8000 + + traefik: + image: traefik:v2.10 + restart: always + security_opt: + - no-new-privileges:true + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.myresolver.acme.tlschallenge=true + - --certificatesresolvers.myresolver.acme.email=${LETSENCRYPT_EMAIL} + - --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json + ports: + - 443:443 + volumes: + - letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + +volumes: + postgres_data: + letsencrypt: diff --git a/traceability-controller/Dockerfile b/traceability-controller/Dockerfile new file mode 100644 index 0000000..9f37d6f --- /dev/null +++ b/traceability-controller/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /traceability-controller + +ENV VIRTUAL_ENV=/opt/venv +RUN python3 -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +COPY requirements.txt ./ + +RUN pip install --upgrade pip +RUN pip install -r requirements.txt + +COPY app ./app +COPY app config.py main.py ./ + +CMD [ "python", "main.py" ] \ No newline at end of file diff --git a/traceability-controller/app/controllers/askar.py b/traceability-controller/app/controllers/askar.py index 670d5f0..bda4c18 100644 --- a/traceability-controller/app/controllers/askar.py +++ b/traceability-controller/app/controllers/askar.py @@ -4,82 +4,48 @@ import time from app.validations import ValidationException - -def holderClientHashesDataKey(did_label): - """OAuth client who will send presentations `/presentations`""" - return f"holderClientHashes" - - -def issuerClientHashesDataKey(): - """OAuth client who will issue credentials `/credentials/issue`""" - return f"issuerClientHashes" - - -def didDocumentDataKey(did_label): - """Controller documents for web DIDs""" - return f"didDocuments:{did_label}" - - -def statusEntriesDataKey(did_label, statusCredentialId): - """List of registered indexes in a status list""" - return f"statusEntries:{did_label}:{statusCredentialId}" - - -def statusCredentialDataKey(did_label, statusCredentialId): - """Status list credential maintained by an issuer""" - return f"statusCredentials:{did_label}:{statusCredentialId}" - - -def issuedCredentialDataKey(did_label, credentialId): - """Issued credentials of an issuer""" - return f"issuedCredentials:{did_label}:{credentialId}" - - -def recievedCredentialDataKey(did_label, credentialId): - """Credentials recieved through a presentation exchange""" - return f"storedCredentials:{did_label}:{credentialId}" - - -async def provision_public_store(): - await Store.provision( - settings.ASKAR_PUBLIC_STORE, - "raw", - settings.ASKAR_PUBLIC_STORE_KEY, - recreate=False, - ) - - -async def open_store(key): - return await Store.open(settings.ASKAR_PUBLIC_STORE, "raw", key) - - -async def store_data(storeKey, dataKey, data): - store = await open_store(storeKey) - async with store.session() as session: - await session.insert( - "seq", - dataKey.lower(), - json.dumps(data), - {"~plaintag": "a", "enctag": "b"}, +class AskarController: + + def __init__(self, db='traceability'): + self.db = f'{settings.POSTGRES_URI}/{db}' + self.key = settings.ASKAR_KEY + + async def provision(self): + await Store.provision( + self.db, + "raw", + self.key, + recreate=False, ) + async def open(self): + return await Store.open(self.db, "raw", self.key) -async def fetch_data(storeKey, dataKey): - store = await open_store(storeKey) - async with store.session() as session: - data = await session.fetch("seq", dataKey.lower()) - return json.loads(data.value) + async def fetch(self, data_key): + store = await self.open() + async with store.session() as session: + data = await session.fetch("seq", data_key) + return json.loads(data.value) + async def store(self, data_key, data): + store = await self.open() + async with store.session() as session: + await session.insert( + "seq", + data_key, + json.dumps(data), + {"~plaintag": "a", "enctag": "b"}, + ) -async def update_data(storeKey, dataKey, new_data): - store = await open_store(storeKey) - async with store.session() as session: - await session.replace( - "seq", - dataKey.lower(), - json.dumps(new_data), - {"~plaintag": "a", "enctag": "b"}, - ) + async def update(self, data_key, data): + store = await self.open() + async with store.session() as session: + await session.replace( + "seq", + data_key, + json.dumps(data), + {"~plaintag": "a", "enctag": "b"}, + ) async def find_api_key(storeKey, apiKeyHash): diff --git a/traceability-controller/app/controllers/auth.py b/traceability-controller/app/controllers/auth.py index 1946b77..bc5f3db 100644 --- a/traceability-controller/app/controllers/auth.py +++ b/traceability-controller/app/controllers/auth.py @@ -1,18 +1,31 @@ -from app.controllers import askar +from app.controllers.askar import AskarController from config import settings import uuid import hashlib import secrets from app.validations import ValidationException from app.auth.handler import decodeJWT +from app.utils import did_from_label + + +def can_issue(credential, did_label): + """Function to check if the issuer field matches the organization's instance""" + did = ( + credential["issuer"] + if isinstance(credential["issuer"], str) + else credential["issuer"]["id"] + ) + if did != did_from_label(did_label): + raise ValidationException( + status_code=400, content={"message": "Invalid issuer"} + ) + return did async def verify_client_hash(client_id, client_secret): try: - client_secret_hash = hashlib.sha256(client_secret.encode('utf-8')).hexdigest() - data_key = f'issuerClients:{client_id}' - client_data = await askar.fetch_data(settings.ASKAR_PUBLIC_STORE_KEY, data_key) - if client_data['client_secret_hash'] != client_secret_hash: + client_hash = uuid.uuid5(uuid.UUID(client_id), client_secret) + if await AskarController().fetch(f'clientHash:{client_id}') != str(client_hash): raise ValidationException( status_code=400, content={"message": "Invalid client"}, @@ -26,26 +39,17 @@ async def verify_client_hash(client_id, client_secret): async def is_authorized(did_label, request): token = request.headers.get("Authorization").replace("Bearer ", "") - client_id = decodeJWT(token)["client_id"] - data_key = f'issuerClients:{client_id}' - client_data = await askar.fetch_data(settings.ASKAR_PUBLIC_STORE_KEY, data_key) - if client_data['did_label'] != did_label: + request_client_id = decodeJWT(token)["client_id"] + stored_client_id = await AskarController(db=did_label).fetch('clientId') + if request_client_id != stored_client_id: raise ValidationException(status_code=401, content={"message": "Unauthorized"}) async def new_issuer_client(did_label): client_id = uuid.uuid4() client_secret = secrets.token_urlsafe(24) - client_secret_hash = hashlib.sha256(client_secret.encode('utf-8')).hexdigest() - client_data = { - 'did_label': did_label, - 'client_secret_hash': client_secret_hash - } - data_key = f'issuerClients:{client_id}' - await askar.store_data(settings.ASKAR_PUBLIC_STORE_KEY, data_key, client_data) - - # Provision private store - # askarKey = generate_askar_key(client_secret) - # await askar.provision_store(askarKey) + client_hash = uuid.uuid5(client_id, client_secret) + await AskarController().store(f'clientHash:{client_id}', str(client_hash)) + await AskarController(db=did_label).store('clientId', str(client_id)) return client_id, client_secret diff --git a/traceability-controller/app/controllers/did_document.py b/traceability-controller/app/controllers/did_document.py new file mode 100644 index 0000000..d822c07 --- /dev/null +++ b/traceability-controller/app/controllers/did_document.py @@ -0,0 +1,50 @@ +import json +from aries_askar import Store, error +from config import settings +import time +from app.validations import ValidationException +from app.utils import did_from_label +from app.controllers.askar import AskarController + + + +class DidDocumentController: + + def __init__(self, did_label): + self.did_label = did_label + self.id = did_from_label(did_label) + self.context = [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/v2", + "https://w3id.org/traceability/v1" + ] + self.verification_method = [] + self.authentication = [] + self.assertion_method = [] + service = { + 'id': f'{self.id}#traceability-api', + 'type': ["TraceabilityAPI"], + 'serviceEndpoint': f'{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{did_label}' + } + self.service = [service] + + async def add_verkey(self): + verification_method = { + 'id': f'{self.id}#verkey', + 'type': 'Ed25519VerificationKey2018', + 'controller': self.id, + 'publicKeyBase58': await AskarController(self.did_label).fetch('verkey') + } + self.verification_method = [verification_method] + self.authentication = [verification_method['id']] + self.assertion_method = [verification_method['id']] + + def as_json(self): + return { + '@context': self.context, + 'id': self.id, + 'verificationMethod': self.verification_method, + 'authentication': self.authentication, + 'assertionMethod': self.assertion_method, + 'service': self.service, + } \ No newline at end of file diff --git a/traceability-controller/app/controllers/status_list.py b/traceability-controller/app/controllers/status_list.py index 9266352..c2d8233 100644 --- a/traceability-controller/app/controllers/status_list.py +++ b/traceability-controller/app/controllers/status_list.py @@ -3,161 +3,129 @@ from datetime import datetime from bitstring import BitArray from app.controllers import askar, agent, did_web +from app.controllers.traction import TractionController +from app.controllers.askar import AskarController from app.validations import ValidationException +from app.utils import did_from_label import gzip, base64 +import uuid + +class StatusListController: + def __init__(self, did_label): + self.did_label = did_label + self.issuer = did_from_label(did_label) + self.lenght = settings.STATUS_LIST_LENGHT + self.status_endpoint = f"{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{did_label}/credentials/status" + + def generate(self, bitstring): + # https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-generation-algorithm + statusListBitarray = BitArray(bin=bitstring) + statusListCompressed = gzip.compress(statusListBitarray.bytes) + statusList_encoded = base64.urlsafe_b64encode(statusListCompressed).decode("utf-8") + return statusList_encoded + + + def expand(self, encoded_list): + # https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-expansion-algorithm + statusListCompressed = base64.urlsafe_b64decode(encoded_list) + statusListBytes = gzip.decompress(statusListCompressed) + statusListBitarray = BitArray(bytes=statusListBytes) + statusListBitstring = statusListBitarray.bin + return statusListBitstring + + + async def create(self, purpose="revocation"): + # https://www.w3.org/TR/vc-bitstring-status-list/#example-example-bitstringstatuslistcredential + status_list_credential = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/vc/status-list/2021/v1" + ], + "id": f'{self.status_endpoint}/{uuid.uuid4()}', + "issuer": self.issuer, + "issuanceDate": str(datetime.now().isoformat()), + "type": ["VerifiableCredential", "StatusList2021Credential"], + "credentialSubject": { + "type": "StatusList2021", + "encodedList": self.generate(str(0) * self.lenght), + "statusPurpose": purpose + }, + } + status_list_vc = await TractionController(self.did_label).sign_json_ld(status_list_credential) + + await AskarController(self.did_label).store('statusListCredential', status_list_vc) + await AskarController(self.did_label).store('statusListEntries', [0, self.lenght - 1]) + + + async def create_entry(self, status): + # https://www.w3.org/TR/vc-bitstring-status-list/#example-example-statuslistcredential + askar = AskarController(self.did_label) + status_entries = await askar.fetch('statusEntries') + # Find an unoccupied index + status_index = random.choice( + [ + e + for e in range(self.lenght - 1) + if e not in status_entries + ] + ) + status_entries.append(status_index) + await askar.update('statusEntries', status_entries) + status_credential = await askar.fetch('statusCredential')['id'] + + credential_status_id = status_credential['id'] + credential_status = { + 'id': f'{credential_status_id}#{status_index}', + 'type': 'StatusList2021Entry', + 'statusPurpose': status['statusPurpose'], + 'statusListIndex': status_index, + 'statusListCredential': status_credential['id'] + } + + return credential_status -def generate(statusListBitstring): - # https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-generation-algorithm - statusListBitarray = BitArray(bin=statusListBitstring) - statusListCompressed = gzip.compress(statusListBitarray.bytes) - statusList_encoded = base64.urlsafe_b64encode(statusListCompressed).decode("utf-8") - return statusList_encoded - - -def expand(statusListEncoded): - # https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-expansion-algorithm - statusListCompressed = base64.urlsafe_b64decode(statusListEncoded) - statusListBytes = gzip.decompress(statusListCompressed) - statusListBitarray = BitArray(bytes=statusListBytes) - statusListBitstring = statusListBitarray.bin - return statusListBitstring - - -async def create(did_label, statusType, purpose="revocation"): - did = did_web.from_org_id(did_label) - # https://www.w3.org/TR/vc-bitstring-status-list/#example-example-bitstringstatuslistcredential - credential = { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - ], - "issuer": did, - "issuanceDate": str(datetime.now().isoformat()), - "type": ["VerifiableCredential"], - "credentialSubject": { - "type": statusType, - "encodedList": generate(str(0) * settings.STATUS_LIST_LENGHT), - }, - } - if statusType == "StatusList2021": - credential["@context"].append("https://w3id.org/vc/status-list/2021/v1") - credential["type"].append("StatusList2021Credential") - credential["credentialSubject"]["statusPurpose"] = purpose - - elif statusType == "RevocationList2020": - credential["@context"].append("https://w3id.org/vc-revocation-list-2020/v1") - credential["type"].append("RevocationList2020Credential") - - options = { - "verificationMethod": f"{did}#verkey", - "proofPurpose": "AssertionMethod", - } - # TODO, new endpoints for issuance - verkey = agent.get_verkey(did) - statusCredential = agent.sign_json_ld(credential, options, verkey) - statusCredentialId = statusType - - dataKey = askar.statusCredentialDataKey(did_label, statusCredentialId) - await askar.store_data(settings.ASKAR_PUBLIC_STORE_KEY, dataKey, statusCredential) - - dataKey = askar.statusEntriesDataKey(did_label, statusCredentialId) - await askar.store_data( - settings.ASKAR_PUBLIC_STORE_KEY, dataKey, [0, settings.STATUS_LIST_LENGHT - 1] - ) - - -async def create_entry(did_label, statusListCredentialId, purpose="revocation"): - # https://www.w3.org/TR/vc-bitstring-status-list/#example-example-statuslistcredential - - dataKey = askar.statusEntriesDataKey(did_label, statusListCredentialId) - statusListEntries = await askar.fetch_data(settings.ASKAR_PUBLIC_STORE_KEY, dataKey) - # Find an unoccupied index - statusListIndex = random.choice( - [ - e - for e in range(settings.STATUS_LIST_LENGHT - 1) - if e not in statusListEntries - ] - ) - statusListEntries.append(statusListIndex) - await askar.update_data(settings.ASKAR_PUBLIC_STORE_KEY, dataKey, statusListEntries) - - listType = statusListCredentialId - statusListCredentialId = f"{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{did_label}/credentials/status/{statusListCredentialId}" - credentialStatus = {"id": f"{statusListCredentialId}#{statusListIndex}"} - - if listType == "RevocationList2020": - credentialStatus["type"] = "RevocationList2020Status" - credentialStatus["revocationListIndex"] = statusListIndex - credentialStatus["revocationListCredential"] = statusListCredentialId - - elif listType == "StatusList2021": - credentialStatus["type"] = "StatusList2021Entry" - credentialStatus["statusPurpose"] = purpose - credentialStatus["statusListIndex"] = statusListIndex - credentialStatus["statusListCredential"] = statusListCredentialId - - return credentialStatus - - -async def add_credential_status(did_label, credential, status): - if status["type"] == "StatusList2021Entry": - credential["@context"].append("https://w3id.org/vc/status-list/2021/v1") - credential["credentialStatus"] = await create_entry(did_label, "StatusList2021") - elif status["type"] == "RevocationList2020Status": - credential["@context"].append("https://w3id.org/vc-revocation-list-2020/v1") - credential["credentialStatus"] = await create_entry(did_label, "RevocationList2020") - return credential - - -def get_credential_status(vc, statusType): - # https://www.w3.org/TR/vc-bitstring-status-list/#validate-algorithm - if statusType == "RevocationList2020Status": - statusListIndex = vc["credentialStatus"]["revocationListIndex"] - statusListCredentialUri = vc["credentialStatus"]["revocationListCredential"] - elif statusType == "StatusList2021Entry": + def get_credential_status(self, vc, statusType): + # https://www.w3.org/TR/vc-bitstring-status-list/#validate-algorithm statusListIndex = vc["credentialStatus"]["statusListIndex"] statusListCredentialUri = vc["credentialStatus"]["statusListCredential"] - r = requests.get(statusListCredentialUri) - statusListCredential = r.json() - statusListBitstring = expand( - statusListCredential["credentialSubject"]["encodedList"] - ) - statusList = list(statusListBitstring) - credentialStatusBit = statusList[statusListIndex] - return True if credentialStatusBit == "1" else False + r = requests.get(statusListCredentialUri) + statusListCredential = r.json() + statusListBitstring = self.expand( + statusListCredential["credentialSubject"]["encodedList"] + ) + statusList = list(statusListBitstring) + credentialStatusBit = statusList[statusListIndex] + return True if credentialStatusBit == "1" else False -async def change_credential_status(vc, statusBit, did_label, statusListCredentialId): - if statusListCredentialId == "RevocationList2020": - statusList_index = vc["credentialStatus"]["revocationListIndex"] - elif statusListCredentialId == "StatusList2021": + async def change_credential_status(self, vc, statusBit, did_label, statusListCredentialId): statusList_index = vc["credentialStatus"]["statusListIndex"] - dataKey = askar.statusCredentialDataKey(did_label, statusListCredentialId) - statusListCredential = await askar.fetch_data(settings.ASKAR_PUBLIC_STORE_KEY, dataKey) - statusListEncoded = statusListCredential["credentialSubject"]["encodedList"] - statusListBitstring = expand(statusListEncoded) - statusList = list(statusListBitstring) - - statusList[statusList_index] = statusBit - statusListBitstring = "".join(statusList) - statusListEncoded = generate(statusListBitstring) - - statusListCredential["credentialSubject"]["encodedList"] = statusListEncoded - - did = vc["issuer"] if isinstance(vc["issuer"], str) else vc["issuer"]["id"] - verkey = agent.get_verkey(did) - options = { - "verificationMethod": f"{did}#verkey", - "proofPurpose": "AssertionMethod", - } - # Remove old proof - statusListCredential.pop("proof") - statusListCredential = agent.sign_json_ld(statusListCredential, options, verkey) - - return statusListCredential + dataKey = askar.statusCredentialDataKey(did_label, statusListCredentialId) + statusListCredential = await askar.fetch_data(settings.ASKAR_PUBLIC_STORE_KEY, dataKey) + statusListEncoded = statusListCredential["credentialSubject"]["encodedList"] + statusListBitstring = self.expand(statusListEncoded) + statusList = list(statusListBitstring) + + statusList[statusList_index] = statusBit + statusListBitstring = "".join(statusList) + statusListEncoded = self.generate(statusListBitstring) + + statusListCredential["credentialSubject"]["encodedList"] = statusListEncoded + + did = vc["issuer"] if isinstance(vc["issuer"], str) else vc["issuer"]["id"] + verkey = agent.get_verkey(did) + options = { + "verificationMethod": f"{did}#verkey", + "proofPurpose": "AssertionMethod", + } + # Remove old proof + statusListCredential.pop("proof") + statusListCredential = agent.sign_json_ld(statusListCredential, options, verkey) + + return statusListCredential async def get_status_list_credential(did_label, statusListCredentialId): diff --git a/traceability-controller/app/controllers/traction.py b/traceability-controller/app/controllers/traction.py new file mode 100644 index 0000000..353db56 --- /dev/null +++ b/traceability-controller/app/controllers/traction.py @@ -0,0 +1,243 @@ +import requests +from config import settings +from app.models.did_document import DidDocument +from app.controllers.askar import AskarController +from app.utils import did_from_label, bitstring_generate, bitstring_expand +from app.validations import ValidationException +from app.validations import valid_did +from datetime import datetime +import uuid +import random + +class TractionController: + def __init__(self, did_label): + self.endpoint = settings.TRACTION_API_ENDPOINT + self.tenant_id = settings.TRACTION_TENANT_ID + self.api_key = settings.TRACTION_API_KEY + self.did_label = did_label + self.did = did_from_label(did_label) + + endpoint = f"{self.endpoint}/multitenancy/tenant/{self.tenant_id}/token" + body = {"api_key": self.api_key} + r = requests.post(endpoint, json=body) + try: + token = r.json()["token"] + self.headers = {"Authorization": f"Bearer {token}"} + except: + raise ValidationException( + status_code=r.status_code, content={"message": r.text} + ) + + def verify_token(self, token): + headers = {"Authorization": token} + endpoint = f"{self.endpoint}/tenant" + r = requests.get(endpoint, headers=headers) + try: + return r.json() + except: + raise ValidationException( + status_code=r.status_code, content={"message": r.text} + ) + + async def create_did(self, did_method='sov', key_type='ed25519'): + + # Create keypair + endpoint = f"{self.endpoint}/wallet/did/create" + body = {"method": did_method, "options": {"key_type": key_type, "did": self.did}} + r = requests.post(endpoint, headers=self.headers, json=body) + try: + verkey = r.json()["result"]["verkey"] + # Provision askar db and store verkey + await AskarController(self.did_label).provision() + await AskarController(self.did_label).store('verkey', verkey) + except: + raise ValidationException( + status_code=r.status_code, content={"message": r.text} + ) + + async def create_status_list(self, purpose='revocation'): + status_endpoint = f'{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{self.did_label}/credentials/status' + status_list_credential = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/vc/status-list/2021/v1" + ], + "id": f'{status_endpoint}/{uuid.uuid4()}', + "issuer": self.did, + "issuanceDate": str(datetime.now().isoformat()), + "type": ["VerifiableCredential", "StatusList2021Credential"], + "credentialSubject": { + "type": "StatusList2021", + "encodedList": bitstring_generate(str(0) * settings.STATUS_LIST_LENGHT), + "statusPurpose": purpose + }, + } + status_list_vc = await self.sign_json_ld(status_list_credential) + status_entries = [0, settings.STATUS_LIST_LENGHT - 1] + + await AskarController(self.did_label).store('statusListCredential', status_list_vc) + await AskarController(self.did_label).store('statusListEntries', status_entries) + + async def create_status_entry(self, purpose='revocation'): + # https://www.w3.org/TR/vc-bitstring-status-list/#example-example-statuslistcredential + status_entries = await AskarController(self.did_label).fetch('statusListEntries') + # Find an unoccupied index + status_index = random.choice( + [ + e + for e in range(settings.STATUS_LIST_LENGHT - 1) + if e not in status_entries + ] + ) + status_entries.append(status_index) + await AskarController(self.did_label).update('statusListEntries', status_entries) + status_credential = await AskarController(self.did_label).fetch('statusListCredential') + + credential_status_id = status_credential['id'] + credential_status = { + 'id': f'{credential_status_id}#{status_index}', + 'type': 'StatusList2021Entry', + 'statusPurpose': purpose, + 'statusListIndex': status_index, + 'statusListCredential': status_credential['id'] + } + return credential_status + + async def get_status_list_credential(self): + try: + return await AskarController(self.did_label).fetch('statusListCredential') + except: + return ValidationException( + status_code=404, + content={"message": "Status list not found"}, + ) + + + def get_credential_status(self, vc): + # https://www.w3.org/TR/vc-bitstring-status-list/#validate-algorithm + status_index = vc["credentialStatus"]["statusListIndex"] + status_list_endpoint = vc["credentialStatus"]["statusListCredential"] + + r = requests.get(status_list_endpoint) + status_list_vc = r.json() + status_list_bitstring = bitstring_expand( + status_list_vc["credentialSubject"]["encodedList"] + ) + status_list = list(status_list_bitstring) + status_bit = status_list[status_index] + return True if status_bit == "1" else False + + def resolve_did(self, did): + # TODO, improve did validation + if did[:9] == "urn:uuid:": + raise ValidationException(status_code=404, content={"message": "Not found"}) + if "did:" not in did: + raise ValidationException(status_code=400, content={"message": "Invalid DID"}) + + endpoint = f"{settings.VERIFIER_ENDPOINT}/resolver/resolve/{did}" + r = requests.get(endpoint) + try: + return r.json()["did_document"] + except: + raise ValidationException( + status_code=r.status_code, content={"message": r.text} + ) + + async def fetch_did_document(self): + verkey = await AskarController(self.did_label).fetch('verkey') + service_endpoint = f'{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{self.did_label}' + return { + '@context': [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/v2", + "https://w3id.org/traceability/v1" + ], + 'id': self.did, + 'verificationMethod': [{ + 'id': f'{self.did}#verkey', + 'type': 'Ed25519VerificationKey2018', + 'controller': self.did, + 'publicKeyBase58': verkey + }], + 'authentication': [ f'{self.did}#verkey'], + 'assertionMethod': [ f'{self.did}#verkey'], + 'service': [{ + 'id': f'{self.did}#traceability-api', + 'type': ["TraceabilityAPI"], + 'serviceEndpoint': service_endpoint + }] + } + + async def sign_json_ld(self, credential): + options = { + 'type': 'Ed25519Signature2018', + 'proofPurpose': 'AssertionMethod', + 'verificationMethod': f'{self.did}#verkey' + } + verkey = await AskarController(self.did_label).fetch('verkey') + body = {"doc": {"credential": credential, "options": options}, "verkey": verkey} + endpoint = f"{self.endpoint}/jsonld/sign" + r = requests.post(endpoint, headers=self.headers, json=body) + try: + return r.json()["signed_doc"] + except: + raise ValidationException( + status_code=r.status_code, content={"message": r.text} + ) + + def issue_credential(self, credential, options): + endpoint = f"{self.endpoint}/vc/credentials/issue" + body = {"credential": credential, "options": options} + r = requests.post(endpoint, headers=self.headers, json=body) + try: + return r.json()["vc"] + except: + raise ValidationException( + status_code=r.status_code, content={"message": r.text} + ) + + + def verify_credential(self, vc): + endpoint = f"{settings.VERIFIER_ENDPOINT}/vc/credentials/verify" + body = {"verifiableCredential": vc, "options": {}} + r = requests.post(endpoint, json=body) + try: + verifications = r.json() + except: + raise ValidationException( + status_code=r.status_code, content={"message": r.text} + ) + + verifications['checks'] = ['proof'] + verifications['errors'] = [verifications['errors']] if 'errors' in verifications else [] + + if "credentialStatus" in vc: + if self.get_credential_status(vc): + verifications['errors'].append('status') + verifications['checks'].append('status') + + issuance_date = datetime.fromisoformat(vc["issuanceDate"]) + if issuance_date < datetime.now(issuance_date.tzinfo): + verifications['errors'].append('issuanceDate') + verifications['checks'].append('issuanceDate') + + if "expirationDate" in vc: + expiration_date = datetime.fromisoformat(vc["expirationDate"]) + if expiration_date < datetime.now(expiration_date.tzinfo): + verifications['errors'].append('expirationDate') + verifications['checks'].append('expirationDate') + + verifications['verified'] = True if len(verifications['errors']) == 0 else False + + return verifications + + def verify_presentation(self, vp): + body = {"verifiablePresentation": vp, "options": {}} + endpoint = f"{settings.VERIFIER_ENDPOINT}/vc/presentations/verify" + r = requests.post(endpoint, json=body) + try: + return r.json() + except: + raise ValidationException( + status_code=r.status_code, content={"message": r.text} + ) diff --git a/traceability-controller/app/models/did_document.py b/traceability-controller/app/models/did_document.py index c3fa56e..78a2f4d 100644 --- a/traceability-controller/app/models/did_document.py +++ b/traceability-controller/app/models/did_document.py @@ -20,32 +20,32 @@ class VerificationMethod(BaseModel): class DidDocument(BaseModel): id: str = Field() context: List[str] = Field( - alias="@context", default=["https://www.w3.org/ns/did/v1"] + alias="@context", default=[ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/v2", + "https://w3id.org/traceability/v1" + ] ) - service: List[Service] = [] - authentication: List[Union[str, VerificationMethod]] = [] - assertionMethod: List[Union[str, VerificationMethod]] = [] - verificationMethod: List[VerificationMethod] = [] - - def add_verkey(self, verkey, verkey_type): - if verkey_type == "Ed25519VerificationKey2018": - self.context.append("https://w3id.org/security/v2") - self.authentication.append(f"{self.id}#verkey") - self.assertionMethod.append(f"{self.id}#verkey") - verification_method = VerificationMethod( - id=f"{self.id}#verkey", - type=verkey_type, - controller=self.id, - publicKeyBase58=verkey, - ) - self.verificationMethod.append(verification_method) - - def add_service(self, service): - if service == "TraceabilityAPI": - self.context.append("https://w3id.org/traceability/v1") - service = Service( - id=f"{self.id}#traceability-api", - type=["TraceabilityAPI"], - serviceEndpoint=f"{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{self.id.split(':')[-1]}", - ) - self.service.append(service) + service: List[Service] = Field(default=[]) + authentication: List[Union[str, VerificationMethod]] = Field(default=[]) + assertionMethod: List[Union[str, VerificationMethod]] = Field(default=[]) + verificationMethod: List[VerificationMethod] = Field(default=[]) + + def add_verkey(self, verkey): + verification_method = VerificationMethod( + id=f"{self.id}#verkey", + type="Ed25519VerificationKey2018", + controller=self.id, + publicKeyBase58=verkey, + ) + self.verificationMethod.append(verification_method) + self.authentication.append(f"{self.id}#verkey") + self.assertionMethod.append(f"{self.id}#verkey") + + def add_service(self): + service = Service( + id=f"{self.id}#traceability-api", + type=["TraceabilityAPI"], + serviceEndpoint=f"{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{self.id.split(':')[-1]}", + ) + self.service.append(service) diff --git a/traceability-controller/app/routers/authentication.py b/traceability-controller/app/routers/authentication.py index 3f815c0..3da5925 100644 --- a/traceability-controller/app/routers/authentication.py +++ b/traceability-controller/app/routers/authentication.py @@ -12,7 +12,7 @@ async def oauth( client_id: Annotated[str, Form()], client_secret: Annotated[str, Form()] ): - auth.verify_client_hash(client_id, client_secret) + await auth.verify_client_hash(client_id, client_secret) expires_in = 600 payload = {"client_id": client_id, "expires": int(time.time()) + expires_in} token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) diff --git a/traceability-controller/app/routers/credentials.py b/traceability-controller/app/routers/credentials.py index 225bed4..c6a7003 100644 --- a/traceability-controller/app/routers/credentials.py +++ b/traceability-controller/app/routers/credentials.py @@ -1,14 +1,14 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from config import settings -from datetime import datetime from app.validations import ValidationException -from app.controllers import agent, status_list, askar, auth, did_web +from app.controllers.traction import TractionController +from app.controllers.askar import AskarController +from app.controllers import status_list, askar, auth from app.models.web_requests import ( IssueCredentialSchema, UpdateCredentialStatusSchema, VerifyCredentialSchema, - CredentialVerificationResponse, ) from app.auth.bearer import JWTBearer import uuid @@ -43,34 +43,26 @@ async def issue_credential( options = request_body["options"] # Ensure the issuer field in the credential has the right value - did_web.can_issue(credential, did_label) - did = did_web.from_org_id(did_label) - + auth.can_issue(credential, did_label) + + traction = TractionController(did_label) + # Generate a credential id if none is provided if "id" not in credential: credential["id"] = f"urn:uuid:{str(uuid.uuid4())}" - # Fill status information - if "credentialStatus" in options: - status = options.pop("credentialStatus") - credential = await status_list.add_credential_status(did_label, credential, status) - # Default to #verkey as id - options["verificationMethod"] = f"{did}#verkey" - - # Backwards compatibility with old json-ld routes in traction, - # doesn't support created option and requires proofPurpose - if "created" in options: - options.pop("created") - options["proofPurpose"] = "assertionMethod" - verkey = agent.get_verkey(did) - vc = agent.sign_json_ld(credential, options, verkey) + # Fill status information + # if "credentialStatus" in options: + credential['@context'].append('https://w3id.org/vc/status-list/2021/v1') + credential['credentialStatus'] = await traction.create_status_entry() - # New vc-api routes - # vc = agent.issue_credential(credential, options) + # TODO use new issuance endpoint + vc = await traction.sign_json_ld(credential) + if 'created' in options: + vc['proof']['created'] = options['created'] credential_id = credential["id"] - data_key = askar.issuedCredentialDataKey(did_label, credential_id) - await askar.store_data(settings.ASKAR_PUBLIC_STORE_KEY, data_key, vc) + await AskarController(did_label).store(f'credentials:{credential_id}', vc) return JSONResponse(status_code=201, content={"verifiableCredential": vc}) @@ -88,37 +80,8 @@ async def verify_credential( request_body = request_body.model_dump(by_alias=True, exclude_none=True) vc = request_body["verifiableCredential"] - verification = CredentialVerificationResponse() - verification = verification.dict() - verification["verified"] = False - - verified = agent.verify_credential(vc) - if "errors" in verified: - verification["errors"].append(verified["errors"]) - verification["checks"].append("proof") - - # Check credential status - if "credentialStatus" in vc: - # vc['credentialStatus']['purpose'] - status_type = vc["credentialStatus"]["type"] - status = status_list.get_credential_status(vc, status_type) - if status: - verification["errors"].append("revoked") - verification["verifications"] = [{"title": "Revocation", "status": "bad"}] - verification["checks"].append("status") - - # Check expiration date - if "expirationDate" in vc: - expiration_date = datetime.fromisoformat(vc["expirationDate"]) - timezone = expiration_date.tzinfo - time_now = datetime.now(timezone) - if expiration_date < time_now: - verification["errors"].append("expired") - verification["checks"].append("expiration") - - if len(verification["errors"]) == 0: - verification["verified"] = True - return JSONResponse(status_code=200, content=verification) + verifications = TractionController(did_label).verify_credential(vc) + return JSONResponse(status_code=200, content=verifications) @router.post( @@ -180,4 +143,4 @@ async def update_credential_status( summary="Returns a status list credential", ) async def get_status_list_credential(did_label: str, status_credential_id: str): - return await status_list.get_status_list_credential(did_label, status_credential_id) + return await TractionController(did_label).get_status_list_credential() diff --git a/traceability-controller/app/routers/identifiers.py b/traceability-controller/app/routers/identifiers.py index 763cc84..d2ac777 100644 --- a/traceability-controller/app/routers/identifiers.py +++ b/traceability-controller/app/routers/identifiers.py @@ -1,8 +1,10 @@ from fastapi import APIRouter, Depends, Request, Security from app.models.web_requests import CreateDIDWebInput -from app.validations import valid_did -from app.controllers import auth, agent, status_list, did_web +from app.controllers import auth +from app.controllers.traction import TractionController +from app.controllers.askar import AskarController from config import settings +from app.utils import did_from_label from app.auth.bearer import JWTBearer from app.auth.handler import get_api_key import uuid @@ -16,23 +18,19 @@ async def register_org_did( apiKey: str = Security(get_api_key), ): label = vars(request_body)["label"] - + # Generate uuid if no label was provided did_label = label if label else str(uuid.uuid4()) - # Register with traction - await did_web.register(did_label) - did = did_web.from_org_id(did_label) - - # Create Status List VCs - # await status_list.create(did_label, "RevocationList2020") - await status_list.create(did_label, "StatusList2021", purpose="revocation") + traction = TractionController(did_label) + await traction.create_did() + await traction.create_status_list() # Generate client credentials and store hash client_id, client_secret = await auth.new_issuer_client(did_label) return { - "did": did, + "did": did_from_label(did_label), "client_id": client_id, "client_secret": client_secret, "token_audience": f"{settings.HTTPS_BASE}", @@ -47,7 +45,28 @@ async def register_org_did( summary="Get a DID's latest keys, services and capabilities", ) async def get_did(did_label: str): - return await did_web.get_did_document(did_label) + did = did_from_label(did_label) + return { + '@context': [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/v2", + "https://w3id.org/traceability/v1" + ], + 'id': did, + 'verificationMethod': [{ + 'id': f'{did}#verkey', + 'type': 'Ed25519VerificationKey2018', + 'controller': did, + 'publicKeyBase58': await AskarController(did_label).fetch('verkey') + }], + 'authentication': [ f'{did}#verkey'], + 'assertionMethod': [ f'{did}#verkey'], + 'service': [{ + 'id': f'{did}#traceability-api', + 'type': ["TraceabilityAPI"], + 'serviceEndpoint': f'{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{did_label}' + }] + } @router.get( @@ -58,7 +77,4 @@ async def get_did(did_label: str): ) async def get_did(did_label: str, did: str, request: Request): await auth.is_authorized(did_label, request) - valid_did(did) - did_document = agent.resolve_did(did) - - return {"didDocument": did_document} + return {"didDocument": TractionController(did_label).resolve_did(did)} diff --git a/traceability-controller/app/utils.py b/traceability-controller/app/utils.py index 024a06b..555da9f 100644 --- a/traceability-controller/app/utils.py +++ b/traceability-controller/app/utils.py @@ -6,11 +6,27 @@ from hashlib import sha256 from app.controllers import askar from aries_askar.bindings import generate_raw_key +from bitstring import BitArray +import gzip, base64 def generate_client_hash(client_secret, client_id): return str(uuid.uuid5(client_secret, client_id)) +def bitstring_generate(bitstring): + # https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-generation-algorithm + statusListBitarray = BitArray(bin=bitstring) + statusListCompressed = gzip.compress(statusListBitarray.bytes) + statusList_encoded = base64.urlsafe_b64encode(statusListCompressed).decode("utf-8") + return statusList_encoded + +def bitstring_expand(encoded_list): + # https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-expansion-algorithm + statusListCompressed = base64.urlsafe_b64decode(encoded_list) + statusListBytes = gzip.decompress(statusListCompressed) + statusListBitarray = BitArray(bytes=statusListBytes) + statusListBitstring = statusListBitarray.bin + return statusListBitstring def did_from_label(did_label): return f"{settings.DID_WEB_BASE}:{settings.DID_NAMESPACE}:{did_label}" diff --git a/traceability-controller/config.py b/traceability-controller/config.py index ebed3ef..2650821 100644 --- a/traceability-controller/config.py +++ b/traceability-controller/config.py @@ -11,12 +11,10 @@ class Settings(BaseSettings): PROJECT_TITLE: str = "traction-tenant-traceability-controller" PROJECT_VERSION: str = "v0" - WORKERS: str = os.environ["WORKERS"] - TRACEABILITY_CONTROLLER_DOMAIN: str = os.environ["TRACEABILITY_CONTROLLER_DOMAIN"] HTTPS_BASE: str = f"https://{TRACEABILITY_CONTROLLER_DOMAIN}" DID_WEB_BASE: str = f"did:web:{TRACEABILITY_CONTROLLER_DOMAIN}" - DID_NAMESPACE: str = "organizations" + DID_NAMESPACE: str = "organization" TRACTION_API_KEY: str = os.environ["TRACTION_API_KEY"] TRACTION_TENANT_ID: str = os.environ["TRACTION_TENANT_ID"] @@ -30,8 +28,8 @@ class Settings(BaseSettings): POSTGRES_URI: str = os.environ["POSTGRES_URI"] # We derive the public storage askar key from the traction api key - ASKAR_PUBLIC_STORE: str = f"{POSTGRES_URI}/traceability" - ASKAR_PUBLIC_STORE_KEY: str = generate_raw_key(TRACTION_API_KEY) + # ASKAR_PUBLIC_STORE: str = f"{POSTGRES_URI}/traceability" + ASKAR_KEY: str = generate_raw_key(TRACTION_API_KEY) # To be removed when new routes are added to traction VERIFIER_ENDPOINT: str = os.environ["VERIFIER_ENDPOINT"] diff --git a/traceability-controller/main.py b/traceability-controller/main.py index 47847d7..d2072a0 100644 --- a/traceability-controller/main.py +++ b/traceability-controller/main.py @@ -1,14 +1,13 @@ import uvicorn -from app.controllers import askar -from config import settings +from app.controllers.askar import AskarController import asyncio if __name__ == "__main__": # Provision askar public store - # asyncio.run(askar.provision_public_store()) + asyncio.run(AskarController().provision()) uvicorn.run( "app.api:app", host="0.0.0.0", port=8000, - workers=int(settings.WORKERS), + workers=4, ) From f96f5c02dcf8fed2663986d73498bbe97271f18c Mon Sep 17 00:00:00 2001 From: PatStLouis Date: Tue, 26 Mar 2024 15:41:00 +0000 Subject: [PATCH 2/3] pass tests, removed RevocationList2020 Signed-off-by: PatStLouis --- .../app/controllers/askar.py | 5 +- .../app/controllers/auth.py | 4 +- .../app/controllers/traction.py | 41 ++++++++++---- .../app/models/credentials.py | 9 +++- .../app/routers/authentication.py | 4 +- .../app/routers/credentials.py | 51 +++--------------- .../app/routers/identifiers.py | 14 +++-- traceability-controller/app/utils.py | 54 ++++--------------- traceability-controller/app/validations.py | 5 ++ traceability-controller/config.py | 10 ++-- traceability-controller/main.py | 1 - 11 files changed, 82 insertions(+), 116 deletions(-) diff --git a/traceability-controller/app/controllers/askar.py b/traceability-controller/app/controllers/askar.py index bda4c18..ff42d9d 100644 --- a/traceability-controller/app/controllers/askar.py +++ b/traceability-controller/app/controllers/askar.py @@ -3,12 +3,13 @@ from config import settings import time from app.validations import ValidationException +from aries_askar.bindings import generate_raw_key class AskarController: - def __init__(self, db='traceability'): + def __init__(self, db=settings.ASKAR_DEFAULT_DB): self.db = f'{settings.POSTGRES_URI}/{db}' - self.key = settings.ASKAR_KEY + self.key = generate_raw_key(settings.TRACTION_API_KEY) async def provision(self): await Store.provision( diff --git a/traceability-controller/app/controllers/auth.py b/traceability-controller/app/controllers/auth.py index bc5f3db..8df7cfc 100644 --- a/traceability-controller/app/controllers/auth.py +++ b/traceability-controller/app/controllers/auth.py @@ -49,7 +49,7 @@ async def new_issuer_client(did_label): client_id = uuid.uuid4() client_secret = secrets.token_urlsafe(24) client_hash = uuid.uuid5(client_id, client_secret) - await AskarController().store(f'clientHash:{client_id}', str(client_hash)) + await AskarController().store(f'clientHash:{str(client_id)}', str(client_hash)) await AskarController(db=did_label).store('clientId', str(client_id)) - return client_id, client_secret + return str(client_id), client_secret diff --git a/traceability-controller/app/controllers/traction.py b/traceability-controller/app/controllers/traction.py index 353db56..7566fb2 100644 --- a/traceability-controller/app/controllers/traction.py +++ b/traceability-controller/app/controllers/traction.py @@ -1,21 +1,19 @@ import requests from config import settings -from app.models.did_document import DidDocument from app.controllers.askar import AskarController from app.utils import did_from_label, bitstring_generate, bitstring_expand from app.validations import ValidationException -from app.validations import valid_did -from datetime import datetime +from datetime import datetime, timedelta import uuid import random class TractionController: def __init__(self, did_label): + self.did_label = did_label + self.did = did_from_label(did_label) self.endpoint = settings.TRACTION_API_ENDPOINT self.tenant_id = settings.TRACTION_TENANT_ID self.api_key = settings.TRACTION_API_KEY - self.did_label = did_label - self.did = did_from_label(did_label) endpoint = f"{self.endpoint}/multitenancy/tenant/{self.tenant_id}/token" body = {"api_key": self.api_key} @@ -72,10 +70,9 @@ async def create_status_list(self, purpose='revocation'): "statusPurpose": purpose }, } - status_list_vc = await self.sign_json_ld(status_list_credential) status_entries = [0, settings.STATUS_LIST_LENGHT - 1] - await AskarController(self.did_label).store('statusListCredential', status_list_vc) + await AskarController(self.did_label).store('statusListCredential', status_list_credential) await AskarController(self.did_label).store('statusListEntries', status_entries) async def create_status_entry(self, purpose='revocation'): @@ -105,7 +102,10 @@ async def create_status_entry(self, purpose='revocation'): async def get_status_list_credential(self): try: - return await AskarController(self.did_label).fetch('statusListCredential') + status_list_credential = await AskarController(self.did_label).fetch('statusListCredential') + status_list_credential['expirationDate'] = str((datetime.now()+timedelta(minutes=5)).isoformat()) + status_list_vc = await self.sign_json_ld(status_list_credential) + return status_list_vc except: return ValidationException( status_code=404, @@ -127,6 +127,24 @@ def get_credential_status(self, vc): status_bit = status_list[status_index] return True if status_bit == "1" else False + + async def change_credential_status(self, credential_id, status): + vc = await AskarController(self.did_label).fetch(f'credentials:{credential_id}') + status_index = vc["credentialStatus"]["statusListIndex"] + status_bit = status[0]["status"] + status_list_credential = await AskarController(self.did_label).fetch('statusListCredential') + status_list_encoded = status_list_credential["credentialSubject"]["encodedList"] + status_list_bitstring = bitstring_expand(status_list_encoded) + status_list = list(status_list_bitstring) + + status_list[status_index] = status_bit + status_list_bitstring = "".join(status_list) + status_list_encoded = bitstring_generate(status_list_bitstring) + + status_list_credential["credentialSubject"]["encodedList"] = status_list_encoded + + await AskarController(self.did_label).update('statusListCredential', status_list_credential) + def resolve_did(self, did): # TODO, improve did validation if did[:9] == "urn:uuid:": @@ -171,7 +189,7 @@ async def fetch_did_document(self): async def sign_json_ld(self, credential): options = { 'type': 'Ed25519Signature2018', - 'proofPurpose': 'AssertionMethod', + 'proofPurpose': 'assertionMethod', 'verificationMethod': f'{self.did}#verkey' } verkey = await AskarController(self.did_label).fetch('verkey') @@ -208,11 +226,15 @@ def verify_credential(self, vc): status_code=r.status_code, content={"message": r.text} ) + verifications.pop('document') + verifications.pop('results') verifications['checks'] = ['proof'] verifications['errors'] = [verifications['errors']] if 'errors' in verifications else [] + verifications['verified'] = True if len(verifications['errors']) == 0 else False if "credentialStatus" in vc: if self.get_credential_status(vc): + verifications['verified'] = False verifications['errors'].append('status') verifications['checks'].append('status') @@ -227,7 +249,6 @@ def verify_credential(self, vc): verifications['errors'].append('expirationDate') verifications['checks'].append('expirationDate') - verifications['verified'] = True if len(verifications['errors']) == 0 else False return verifications diff --git a/traceability-controller/app/models/credentials.py b/traceability-controller/app/models/credentials.py index a42d945..30ce23a 100644 --- a/traceability-controller/app/models/credentials.py +++ b/traceability-controller/app/models/credentials.py @@ -8,6 +8,7 @@ valid_type, valid_context_v1, valid_status_update_value, + valid_status_purpose ) from .proofs import Proof @@ -136,7 +137,7 @@ def validate_created(cls, value): class CredentialStatusUpdateItem(BaseModel): type: str = Field() status: str = Field() - status_purpose: str = Field(None, alias="statusPurpose") + status_purpose: str = Field(alias="statusPurpose") @field_validator("type") @classmethod @@ -149,3 +150,9 @@ def validate_status_entry_type(cls, value): def validate_status(cls, value): valid_status_update_value(value) return value + + @field_validator("status_purpose") + @classmethod + def validate_status_purpose(cls, value): + valid_status_purpose(value) + return value diff --git a/traceability-controller/app/routers/authentication.py b/traceability-controller/app/routers/authentication.py index 3da5925..b1b2c83 100644 --- a/traceability-controller/app/routers/authentication.py +++ b/traceability-controller/app/routers/authentication.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Form +from fastapi.responses import JSONResponse from typing import Annotated from config import settings from app.controllers import auth @@ -16,4 +17,5 @@ async def oauth( expires_in = 600 payload = {"client_id": client_id, "expires": int(time.time()) + expires_in} token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) - return {"access_token": token, "token_type": "Bearer", "expires_in": expires_in} + response = {"access_token": token, "token_type": "Bearer", "expires_in": expires_in} + return JSONResponse(status_code=200, content=response) diff --git a/traceability-controller/app/routers/credentials.py b/traceability-controller/app/routers/credentials.py index c6a7003..1f670ed 100644 --- a/traceability-controller/app/routers/credentials.py +++ b/traceability-controller/app/routers/credentials.py @@ -1,10 +1,9 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from config import settings -from app.validations import ValidationException from app.controllers.traction import TractionController from app.controllers.askar import AskarController -from app.controllers import status_list, askar, auth +from app.controllers import auth from app.models.web_requests import ( IssueCredentialSchema, UpdateCredentialStatusSchema, @@ -24,7 +23,8 @@ ) async def get_credential(did_label: str, credential_id: str, request: Request): await auth.is_authorized(did_label, request) - return await askar.get_credential(did_label, credential_id) + credential = await AskarController(did_label).fetch(f'credentials:{credential_id}') + return JSONResponse(status_code=200, content=credential) @router.post( @@ -52,14 +52,13 @@ async def issue_credential( credential["id"] = f"urn:uuid:{str(uuid.uuid4())}" # Fill status information - # if "credentialStatus" in options: credential['@context'].append('https://w3id.org/vc/status-list/2021/v1') credential['credentialStatus'] = await traction.create_status_entry() # TODO use new issuance endpoint vc = await traction.sign_json_ld(credential) - if 'created' in options: - vc['proof']['created'] = options['created'] + # if 'created' in options: + # vc['proof']['created'] = options['created'] credential_id = credential["id"] await AskarController(did_label).store(f'credentials:{credential_id}', vc) @@ -97,42 +96,7 @@ async def update_credential_status( request_body = request_body.model_dump(by_alias=True, exclude_none=True) credential_id = request_body["credentialId"] credential_status = request_body["credentialStatus"] - - # We find which status bit to update - status_bit = credential_status[0]["status"] - status_type = credential_status[0]["type"] - if ( - credential_status[0]["type"] in ["StatusList2021Entry"] - and "statusPurpose" not in credential_status[0] - ): - raise ValidationException( - status_code=400, content={"message": "Missing purpose"} - ) - - # We fetch the issued credential based on the credential ID - data_key = askar.issuedCredentialDataKey(did_label, credential_id) - vc = await askar.fetch_data(settings.ASKAR_PUBLIC_STORE_KEY, data_key) - - # Make sure the payload refers to the correct status list type - if vc["credentialStatus"]["type"] != status_type: - return ValidationException( - status_code=400, - content={"message": f"Status list type mismatch"}, - ) - if status_type == "RevocationList2020Status": - status_credential_id = status_list_type = "RevocationList2020" - status_credential = await status_list.change_credential_status( - vc, status_bit, did_label, status_list_type - ) - elif status_type == "StatusList2021Entry": - status_credential_id = status_list_type = "StatusList2021" - status_credential = await status_list.change_credential_status( - vc, status_bit, did_label, status_list_type - ) - - # Store the new credential - data_key = askar.statusCredentialDataKey(did_label, status_credential_id) - await askar.update_data(settings.ASKAR_PUBLIC_STORE_KEY, data_key, status_credential) + await TractionController(did_label).change_credential_status(credential_id, credential_status) return JSONResponse(status_code=200, content={"message": "Status updated"}) @@ -143,4 +107,5 @@ async def update_credential_status( summary="Returns a status list credential", ) async def get_status_list_credential(did_label: str, status_credential_id: str): - return await TractionController(did_label).get_status_list_credential() + status_credential = await TractionController(did_label).get_status_list_credential() + return JSONResponse(status_code=200, content=status_credential) diff --git a/traceability-controller/app/routers/identifiers.py b/traceability-controller/app/routers/identifiers.py index d2ac777..650bedc 100644 --- a/traceability-controller/app/routers/identifiers.py +++ b/traceability-controller/app/routers/identifiers.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, Request, Security +from fastapi.responses import JSONResponse from app.models.web_requests import CreateDIDWebInput from app.controllers import auth from app.controllers.traction import TractionController @@ -28,16 +29,17 @@ async def register_org_did( # Generate client credentials and store hash client_id, client_secret = await auth.new_issuer_client(did_label) - - return { + response = { "did": did_from_label(did_label), - "client_id": client_id, + "client_id": str(client_id), "client_secret": client_secret, "token_audience": f"{settings.HTTPS_BASE}", "token_endpoint": f"{settings.HTTPS_BASE}/oauth/token", "service_endpoint": f"{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{did_label}", } + return JSONResponse(status_code=201, content=response) + @router.get( "/{did_label}/did.json", @@ -46,7 +48,7 @@ async def register_org_did( ) async def get_did(did_label: str): did = did_from_label(did_label) - return { + did_document = { '@context': [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/v2", @@ -67,6 +69,7 @@ async def get_did(did_label: str): 'serviceEndpoint': f'{settings.HTTPS_BASE}/{settings.DID_NAMESPACE}/{did_label}' }] } + return JSONResponse(status_code=200, content=did_document) @router.get( @@ -77,4 +80,5 @@ async def get_did(did_label: str): ) async def get_did(did_label: str, did: str, request: Request): await auth.is_authorized(did_label, request) - return {"didDocument": TractionController(did_label).resolve_did(did)} + response = {"didDocument": TractionController(did_label).resolve_did(did)} + return JSONResponse(status_code=200, content=response) diff --git a/traceability-controller/app/utils.py b/traceability-controller/app/utils.py index 555da9f..a385c79 100644 --- a/traceability-controller/app/utils.py +++ b/traceability-controller/app/utils.py @@ -15,55 +15,19 @@ def generate_client_hash(client_secret, client_id): def bitstring_generate(bitstring): # https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-generation-algorithm - statusListBitarray = BitArray(bin=bitstring) - statusListCompressed = gzip.compress(statusListBitarray.bytes) - statusList_encoded = base64.urlsafe_b64encode(statusListCompressed).decode("utf-8") - return statusList_encoded + status_list_bitstring_array = BitArray(bin=bitstring) + status_list_compressed = gzip.compress(status_list_bitstring_array.bytes) + status_list_encoded = base64.urlsafe_b64encode(status_list_compressed).decode("utf-8").strip('=') + return status_list_encoded def bitstring_expand(encoded_list): # https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-expansion-algorithm - statusListCompressed = base64.urlsafe_b64decode(encoded_list) - statusListBytes = gzip.decompress(statusListCompressed) - statusListBitarray = BitArray(bytes=statusListBytes) - statusListBitstring = statusListBitarray.bin - return statusListBitstring + status_list_compressed = base64.urlsafe_b64decode(encoded_list+'==') + status_list_bytes = gzip.decompress(status_list_compressed) + status_list_bit_array = BitArray(bytes=status_list_bytes) + status_list_bitstring = status_list_bit_array.bin + return status_list_bitstring def did_from_label(did_label): return f"{settings.DID_WEB_BASE}:{settings.DID_NAMESPACE}:{did_label}" - -def generate_askar_key(seed): - return generate_raw_key(seed) - - -async def new_client(did_label): - did_label_hash = hashlib.md5(did_label.encode()) - client_id = uuid.UUID(did_label_hash.hexdigest()) - client_secret = secrets.token_urlsafe(24) - client_hash = str(uuid.uuid5(client_id, client_secret)) - - await askar.append_client_hash(settings.ASKAR_PUBLIC_STORE_KEY, client_hash) - # askar_key = generate_askar_key(client_secret) - - # Provision private store - # await askar.provision_store(askar_key) - - return client_id, client_secret - - -async def new_issuer_client(did_label): - client_id = uuid.uuid4() - client_secret = secrets.token_urlsafe(24) - client_secret_hash = sha256(client_secret.encode('utf-8')).hexdigest() - client_data = { - 'did_label': did_label, - 'client_secret_hash': client_secret_hash - } - data_key = f'issuerClients:{client_id}' - await askar.store_data(settings.ASKAR_PUBLIC_STORE_KEY, data_key, client_data) - - # Provision private store - # askarKey = generate_askar_key(client_secret) - # await askar.provision_store(askarKey) - - return client_id, client_secret diff --git a/traceability-controller/app/validations.py b/traceability-controller/app/validations.py index b1891ef..bf07059 100644 --- a/traceability-controller/app/validations.py +++ b/traceability-controller/app/validations.py @@ -20,6 +20,11 @@ def valid_credential_subject(credential_subject): raise ValueError("Invalid subject id") +def valid_status_purpose(status_purpose): + if not isinstance(status_purpose, str): + raise ValueError("Invalid status purpose") + + def valid_context_v1(context): if len(context) < 1: raise ValueError("Must have items") diff --git a/traceability-controller/config.py b/traceability-controller/config.py index 2650821..73eb05b 100644 --- a/traceability-controller/config.py +++ b/traceability-controller/config.py @@ -1,7 +1,6 @@ from pydantic_settings import BaseSettings import os from dotenv import load_dotenv -from aries_askar.bindings import generate_raw_key basedir = os.path.abspath(os.path.dirname(__file__)) load_dotenv(os.path.join(basedir, ".env")) @@ -11,10 +10,10 @@ class Settings(BaseSettings): PROJECT_TITLE: str = "traction-tenant-traceability-controller" PROJECT_VERSION: str = "v0" - TRACEABILITY_CONTROLLER_DOMAIN: str = os.environ["TRACEABILITY_CONTROLLER_DOMAIN"] - HTTPS_BASE: str = f"https://{TRACEABILITY_CONTROLLER_DOMAIN}" - DID_WEB_BASE: str = f"did:web:{TRACEABILITY_CONTROLLER_DOMAIN}" + DOMAIN: str = os.environ["TRACEABILITY_CONTROLLER_DOMAIN"] DID_NAMESPACE: str = "organization" + HTTPS_BASE: str = f"https://{DOMAIN}" + DID_WEB_BASE: str = f"did:web:{DOMAIN}" TRACTION_API_KEY: str = os.environ["TRACTION_API_KEY"] TRACTION_TENANT_ID: str = os.environ["TRACTION_TENANT_ID"] @@ -28,8 +27,7 @@ class Settings(BaseSettings): POSTGRES_URI: str = os.environ["POSTGRES_URI"] # We derive the public storage askar key from the traction api key - # ASKAR_PUBLIC_STORE: str = f"{POSTGRES_URI}/traceability" - ASKAR_KEY: str = generate_raw_key(TRACTION_API_KEY) + ASKAR_DEFAULT_DB: str = 'traceability' # To be removed when new routes are added to traction VERIFIER_ENDPOINT: str = os.environ["VERIFIER_ENDPOINT"] diff --git a/traceability-controller/main.py b/traceability-controller/main.py index d2072a0..9ab8968 100644 --- a/traceability-controller/main.py +++ b/traceability-controller/main.py @@ -3,7 +3,6 @@ import asyncio if __name__ == "__main__": - # Provision askar public store asyncio.run(AskarController().provision()) uvicorn.run( "app.api:app", From 645043b2e769e901665662200db9e2910d21412d Mon Sep 17 00:00:00 2001 From: PatStLouis Date: Thu, 28 Mar 2024 03:21:01 +0000 Subject: [PATCH 3/3] pre merge Signed-off-by: PatStLouis --- docker-compose.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3630472..1683fe7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,8 +27,9 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} traceability-api: - build: - context: ./traceability-controller + image: patstlouis91/traceability-controller:interop-0.0.1 + # build: + # context: ./traceability-controller ports: - 8000:8000 entrypoint: ["python", "main.py"]