diff --git a/.dockerignore b/.dockerignore index 5840609..0172a4d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,4 @@ .env* -__pycache__/ \ No newline at end of file +*cache* +*.db +venv/ \ No newline at end of file diff --git a/docker/Dockerfile b/server/Dockerfile similarity index 71% rename from docker/Dockerfile rename to server/Dockerfile index cf99042..4dfb052 100644 --- a/docker/Dockerfile +++ b/server/Dockerfile @@ -1,14 +1,17 @@ FROM python:3.12 -WORKDIR /trustdidweb-server-py +WORKDIR /fastapi ENV VIRTUAL_ENV=/opt/venv RUN python3 -m venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" -COPY server/* ./ +COPY requirements.txt ./ RUN pip install --upgrade pip RUN pip install -r requirements.txt +COPY app ./app +COPY config.py main.py ./ + CMD [ "python", "main.py" ] \ No newline at end of file diff --git a/server/app/__init__.py b/server/app/__init__.py index e69de29..18f1d16 100644 --- a/server/app/__init__.py +++ b/server/app/__init__.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI, APIRouter +from fastapi.responses import JSONResponse +from app.routers import identifiers +from config import settings +from app.plugins import AskarStorage + +app = FastAPI(title=settings.PROJECT_TITLE, version=settings.PROJECT_VERSION) + + +api_router = APIRouter() + + +@api_router.get("/server/status", tags=["Server"], include_in_schema=False) +async def server_status(): + return JSONResponse(status_code=200, content={"status": "ok"}) + + +@api_router.get("/.well-known/did.json", tags=["Server"], include_in_schema=False) +async def get_main_did_document(): + did_document = await AskarStorage().fetch("didDocument", settings.DID_WEB_BASE) + return JSONResponse(status_code=200, content=did_document) + + +api_router.include_router(identifiers.router, tags=["Identifiers"]) + + +app.include_router(api_router) diff --git a/server/app/api.py b/server/app/api.py deleted file mode 100644 index 60ccaad..0000000 --- a/server/app/api.py +++ /dev/null @@ -1,20 +0,0 @@ -from fastapi import FastAPI, APIRouter -from fastapi.responses import JSONResponse -from app.routers import identifiers -from config import settings - -app = FastAPI(title=settings.PROJECT_TITLE, version=settings.PROJECT_VERSION) - - -api_router = APIRouter() - -@api_router.get("/server/status", tags=["Server"], include_in_schema=False) -async def server_status(): - return JSONResponse(status_code=200, content={"status": "ok"}) - -api_router.include_router(identifiers.router, tags=["Identifiers"]) - - - - -app.include_router(api_router) diff --git a/server/app/dependencies.py b/server/app/dependencies.py index ccc86c3..d14e1b2 100644 --- a/server/app/dependencies.py +++ b/server/app/dependencies.py @@ -1,44 +1,13 @@ from fastapi import HTTPException from config import settings -from app.models.web_requests import RegisterDID -from app.plugins import AskarVerifier, AskarStorage -from app.utilities import find_key, find_proof +from app.plugins import AskarStorage async def identifier_available(namespace: str, identifier: str): - if await AskarStorage().fetch("didDocument", f"{namespace}:{identifier}"): + if await AskarStorage().fetch("didDocument", f"{settings.DID_WEB_BASE}:{namespace}:{identifier}"): raise HTTPException(status_code=409, detail="Identifier unavailable.") async def did_document_exists(namespace: str, identifier: str): - if not await AskarStorage().fetch("didDocument", f"{namespace}:{identifier}"): + if not await AskarStorage().fetch("didDocument", f"{settings.DID_WEB_BASE}:{namespace}:{identifier}"): raise HTTPException(status_code=404, detail="Ressource not found.") - - -async def valid_did_registration( - namespace: str, identifier: str, request_body: RegisterDID -): - document = request_body.model_dump(by_alias=True, exclude_none=True)["didDocument"] - proofs = document.pop("proof") - try: - assert ( - document["id"] == f"{settings.DID_WEB_BASE}:{namespace}:{identifier}" - ), "Id mismatch between DID Document and requested endpoint." - assert ( - len(document["verificationMethod"]) >= 1 - ), "DID Documentmust contain at least 1 verificationMethod." - assert ( - isinstance(proofs, list) and len(proofs) == 2 - ), "Insuficient proofs, must contain a client and an endorser proof." - except AssertionError as msg: - raise HTTPException(status_code=400, detail=str(msg)) - - endorser_proof = find_proof(proofs, f"{settings.DID_WEB_BASE}#key-01") - endorser_key = settings.ENDORSER_MULTIKEY - AskarVerifier(endorser_key).verify_proof(document, endorser_proof) - - client_proof = find_proof(proofs, document["verificationMethod"][0]["id"]) - client_key = find_key(document, client_proof["verificationMethod"]) - AskarVerifier(client_key).verify_proof(document, client_proof) - - return document diff --git a/server/app/models/di_proof.py b/server/app/models/di_proof.py index 4df0216..795d37b 100644 --- a/server/app/models/di_proof.py +++ b/server/app/models/di_proof.py @@ -1,5 +1,10 @@ -from typing import Union, List, Dict -from pydantic import BaseModel, Field, AliasChoices, field_validator +from typing import Dict, Any +from pydantic import BaseModel, Field, field_validator + + +class BaseModel(BaseModel): + def model_dump(self, **kwargs) -> Dict[str, Any]: + return super().model_dump(by_alias=True, exclude_none=True, **kwargs) class DataIntegrityProof(BaseModel): @@ -8,10 +13,10 @@ class DataIntegrityProof(BaseModel): verification_method: str = Field(alias="verificationMethod") proof_value: str = Field(alias="proofValue") proof_purpose: str = Field(alias="proofPurpose") - domain: str = Field() - challenge: str = Field() - created: str = Field() - expires: str = Field() + domain: str = Field(None) + challenge: str = Field(None) + created: str = Field(None) + expires: str = Field(None) @field_validator("type") @classmethod @@ -26,3 +31,8 @@ def validate_cryptosuite(cls, value): if value not in ["eddsa-jcs-2022"]: raise ValueError("Unsupported cryptosuite") return value + + @field_validator("expires") + @classmethod + def validate_expires(cls, value): + return value diff --git a/server/app/models/did_document.py b/server/app/models/did_document.py index 4f66586..a31c825 100644 --- a/server/app/models/did_document.py +++ b/server/app/models/did_document.py @@ -1,6 +1,12 @@ -from typing import Union, List, Dict -from pydantic import BaseModel, Field +from typing import Union, List, Dict, Any +from pydantic import BaseModel, Field, field_validator from .di_proof import DataIntegrityProof +import re + + +class BaseModel(BaseModel): + def model_dump(self, **kwargs) -> Dict[str, Any]: + return super().model_dump(by_alias=True, exclude_none=True, **kwargs) class VerificationMethod(BaseModel): @@ -20,14 +26,56 @@ class DidDocument(BaseModel): context: Union[str, List[str]] = Field( ["https://www.w3.org/ns/did/v1"], alias="@context" ) - id: str = Field(None) + id: str = Field() controller: str = Field(None) alsoKnownAs: List[str] = Field(None) - verificationMethod: List[VerificationMethod] = Field(None) - authentication: List[Union[str, VerificationMethod]] = Field(None) - assertionMethod: List[Union[str, VerificationMethod]] = Field(None) + verificationMethod: List[VerificationMethod] = Field() + authentication: List[Union[str, VerificationMethod]] = Field() + assertionMethod: List[Union[str, VerificationMethod]] = Field() keyAgreement: List[Union[str, VerificationMethod]] = Field(None) capabilityInvocation: List[Union[str, VerificationMethod]] = Field(None) capabilityDelegation: List[Union[str, VerificationMethod]] = Field(None) service: List[Service] = Field(None) - proof: Union[DataIntegrityProof, List[DataIntegrityProof]] = Field(None) + + @field_validator("context") + @classmethod + def validate_context(cls, value): + assert value[0] == "https://www.w3.org/ns/did/v1", "Invalid context." + return value + + @field_validator("id") + @classmethod + def validate_id(cls, value): + DID_REGEX = re.compile( + "did:([a-z0-9]+):((?:[a-zA-Z0-9._%-]*:)*[a-zA-Z0-9._%-]+)" + ) + assert DID_REGEX.match(value), "Expected id to be a DID." + return value + + @field_validator("authentication") + @classmethod + def validate_authentication(cls, value): + assert len(value) >= 1, "Expected at least one authentication method." + return value + + @field_validator("assertionMethod") + @classmethod + def validate_assertion_method(cls, value): + assert len(value) >= 1, "Expected at least one assertion method." + return value + + @field_validator("verificationMethod") + @classmethod + def validate_verification_method(cls, value): + assert len(value) >= 1, "Expected at least one verification method." + return value + + +class SecuredDidDocument(DidDocument): + proof: List[DataIntegrityProof] = Field(None) + + @field_validator("proof") + @classmethod + def validate_proof(cls, value): + assert len(value) == 2, "Expected proof set." + return value diff --git a/server/app/models/web_requests.py b/server/app/models/web_requests.py deleted file mode 100644 index d97f639..0000000 --- a/server/app/models/web_requests.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Union, List, Dict -from pydantic import BaseModel, Field -from .did_document import DidDocument - - -class RegisterDID(BaseModel): - didDocument: DidDocument = Field() diff --git a/server/app/models/web_schemas.py b/server/app/models/web_schemas.py new file mode 100644 index 0000000..faf66d8 --- /dev/null +++ b/server/app/models/web_schemas.py @@ -0,0 +1,12 @@ +from typing import Dict, Any +from pydantic import BaseModel, Field +from .did_document import SecuredDidDocument + + +class BaseModel(BaseModel): + def model_dump(self, **kwargs) -> Dict[str, Any]: + return super().model_dump(by_alias=True, exclude_none=True, **kwargs) + + +class RegisterDID(BaseModel): + didDocument: SecuredDidDocument = Field() diff --git a/server/app/plugins/askar.py b/server/app/plugins/askar.py index e9adeb7..522266b 100644 --- a/server/app/plugins/askar.py +++ b/server/app/plugins/askar.py @@ -1,6 +1,6 @@ import json from fastapi import HTTPException -from aries_askar import Store, error, Key +from aries_askar import Store, Key from aries_askar.bindings import LocalKeyHandle from config import settings from app.utilities import create_did_doc @@ -78,8 +78,7 @@ def __init__(self, multikey=None): alg="ed25519", public=bytes(bytearray(multibase.decode(multikey))[2:]) ) - def create_proof_config(self): - created = str(datetime.now(timezone.utc).isoformat("T", "seconds")) + def create_proof_config(self, did): expires = str( (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat( "T", "seconds" @@ -89,17 +88,24 @@ def create_proof_config(self): "type": self.type, "cryptosuite": self.cryptosuite, "proofPurpose": self.purpose, - "created": created, + "verificationMethod": f"{settings.DID_WEB_BASE}#key-01", "expires": expires, "domain": settings.DOMAIN, - "challenge": self.create_challenge(created + expires), + "challenge": self.create_challenge(did + expires), } def create_challenge(self, value): return str(uuid.uuid5(uuid.NAMESPACE_DNS, settings.SECRET_KEY + value)) - def assert_proof_options(self, proof): + def assert_proof_options(self, proof, did): try: + assert datetime.fromisoformat(proof["expires"]) > datetime.now( + timezone.utc + ), "Proof expired." + assert proof["domain"] == settings.DOMAIN, "Domain mismatch." + assert proof["challenge"] == self.create_challenge( + did + proof["expires"] + ), "Challenge mismatch." assert proof["type"] == self.type, f"Expected {self.type} proof type." assert ( proof["cryptosuite"] == self.cryptosuite @@ -107,27 +113,21 @@ def assert_proof_options(self, proof): assert ( proof["proofPurpose"] == self.purpose ), f"Expected {self.purpose} proof purpose." - assert proof["domain"] == settings.DOMAIN, "Domain mismatch." - assert proof["challenge"] == self.create_challenge( - proof["created"] + proof["expires"] - ), "Challenge mismatch." - assert datetime.fromisoformat(proof["created"]) < datetime.now( - timezone.utc - ), "Invalid proof creation timestamp." - assert datetime.fromisoformat(proof["expires"]) > datetime.now( - timezone.utc - ), "Proof expired." - assert datetime.fromisoformat(proof["created"]) < datetime.fromisoformat( - proof["expires"] - ), "Proof validity period invalid." except AssertionError as msg: raise HTTPException(status_code=400, detail=str(msg)) def verify_proof(self, document, proof): - self.assert_proof_options(proof) - assert ( - proof["verificationMethod"].split("#")[0] == document["id"] - or proof["verificationMethod"].split("#")[0] == settings.DID_WEB_BASE + self.assert_proof_options(proof, document['id']) + + # Set multikey to endorser multikey if verificationMethod matches else expect did:key: + multikey = ( + settings.ENDORSER_MULTIKEY + if proof["verificationMethod"] == f'{settings.DID_WEB_BASE}#key-01' + else proof["verificationMethod"].split("#")[-1] + ) + + self.key = Key(LocalKeyHandle()).from_public_bytes( + alg="ed25519", public=bytes(bytearray(multibase.decode(multikey))[2:]) ) proof_options = proof.copy() diff --git a/server/app/routers/identifiers.py b/server/app/routers/identifiers.py index ff92492..6f89452 100644 --- a/server/app/routers/identifiers.py +++ b/server/app/routers/identifiers.py @@ -1,39 +1,62 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse -from app.models.web_requests import RegisterDID +from app.models.web_schemas import RegisterDID from config import settings from app.plugins import AskarVerifier, AskarStorage from app.dependencies import ( - identifier_available, did_document_exists, - valid_did_registration, ) -from app.utilities import create_did_doc_template +from app.plugins import AskarVerifier, AskarStorage router = APIRouter() -@router.get("/{namespace}/{identifier}", summary="Request DID configuration.") -async def get_did( - namespace: str, identifier: str, dependency=Depends(identifier_available) +@router.get("/", summary="Request DID configuration.") +async def request_did( + namespace: str = None, + identifier: str = None, ): - return JSONResponse( - status_code=200, - content={ - "document": create_did_doc_template(namespace, identifier), - "options": AskarVerifier().create_proof_config(), - }, - ) + if namespace and identifier: + did = f"{settings.DID_WEB_BASE}:{namespace}:{identifier}" + if await AskarStorage().fetch("didDocument", did): + raise HTTPException( + status_code=409, detail="Requested identifier unavailable." + ) + + return JSONResponse( + status_code=200, + content={ + "id": did, + "proofOptions": AskarVerifier().create_proof_config(did), + }, + ) + + raise HTTPException(status_code=418) -@router.post("/{namespace}/{identifier}", summary="Register DID.") +@router.post("/", summary="Register DID.") async def register_did( request_body: RegisterDID, - namespace: str, - identifier: str, - did_document=Depends(valid_did_registration), ): - await AskarStorage().store("didDocument", f"{namespace}:{identifier}", did_document) + did_document = request_body.model_dump()['didDocument'] + + # Extract and verify the proofs + proof_set = did_document.pop("proof", None) + for proof in proof_set: + AskarVerifier().verify_proof(did_document, proof) + if proof['verificationMethod'].startswith('did:key:'): + authorized_key = proof['verificationMethod'].split('#')[-1] + + # Ensure DID is available + did = did_document["id"] + if await AskarStorage().fetch("didDocument", did): + raise HTTPException( + status_code=409, detail="Requested identifier unavailable." + ) + + # Store document and authorized key + await AskarStorage().store("didDocument", did, did_document) + await AskarStorage().store("authorizedKey", did, authorized_key) return JSONResponse(status_code=201, content={"didDocument": did_document}) @@ -52,8 +75,11 @@ async def delete_did( @router.get("/{namespace}/{identifier}/did.json", summary="Get DID document.") -async def get_did( +async def get_did_document( namespace: str, identifier: str, dependency=Depends(did_document_exists) ): - did_doc = await AskarStorage().fetch("didDocument", f"{namespace}:{identifier}") - return JSONResponse(status_code=200, content=did_doc) + did = f"{settings.DID_WEB_BASE}:{namespace}:{identifier}" + did_doc = await AskarStorage().fetch("didDocument", did) + if did_doc: + return JSONResponse(status_code=200, content=did_doc) + raise HTTPException(status_code=404, detail="Ressource not found.") diff --git a/server/app/utilities.py b/server/app/utilities.py index 3bfa18a..d8e9878 100644 --- a/server/app/utilities.py +++ b/server/app/utilities.py @@ -1,13 +1,18 @@ from app.models.did_document import DidDocument from config import settings -def create_did_doc(did, multikey, kid='key-01'): + +def derive_did(namespace, identifier): + return f"{settings.DID_WEB_BASE}:{namespace}:{identifier}" + + +def create_did_doc(did, multikey, kid="key-01"): return DidDocument( id=did, verificationMethod=[ { "id": kid, - "type": "MultiKey", + "type": "Multikey", "controller": did, "publicKeyMultibase": multikey, } @@ -15,18 +20,13 @@ def create_did_doc(did, multikey, kid='key-01'): authentication=[kid], assertionMethod=[kid], service=[], - ).dict(by_alias=True, exclude_none=True) - -def create_did_doc_template(namespace, identifier): - return DidDocument( - id=f"{settings.DID_WEB_BASE}:{namespace}:{identifier}", - ).dict(by_alias=True, exclude_none=True) + ).model_dump() def find_key(did_doc, kid): return next( ( - vm['publicKeyMultibase'] + vm["publicKeyMultibase"] for vm in did_doc["verificationMethod"] if vm["id"] == kid ), @@ -34,12 +34,8 @@ def find_key(did_doc, kid): ) -def find_proof(proofs, kid): +def find_proof(proof_set, kid): return next( - ( - proof - for proof in proofs - if proof["verificationMethod"] == kid - ), + (proof for proof in proof_set if proof["verificationMethod"] == kid), None, - ) \ No newline at end of file + ) diff --git a/server/config.py b/server/config.py index 46a11e6..4468b2a 100644 --- a/server/config.py +++ b/server/config.py @@ -1,7 +1,5 @@ from pydantic_settings import BaseSettings import os -import uuid -import secrets from dotenv import load_dotenv basedir = os.path.abspath(os.path.dirname(__file__)) diff --git a/server/main.py b/server/main.py index 2364a41..891595c 100644 --- a/server/main.py +++ b/server/main.py @@ -5,7 +5,7 @@ if __name__ == "__main__": asyncio.run(AskarStorage().provision()) uvicorn.run( - "app.api:app", + "app:app", host="0.0.0.0", port=8000, reload=True,