diff --git a/README.md b/README.md index e2e7416..a1fb514 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,11 @@ sequenceDiagram - DID log support - whois VP support - AnonCreds objects support -- Status lists support \ No newline at end of file +- Status lists support + +```bash +In a web where trust is born anew, +Decentralized keys unlock our view. +We shape the code, and break the chain, +Trust in our hands will always reign. +``` \ No newline at end of file diff --git a/demo/manage b/demo/manage index ac28399..03bb4d9 100755 --- a/demo/manage +++ b/demo/manage @@ -30,11 +30,11 @@ sudo docker compose --env-file .env.demo up --build sudo docker compose --env-file .env.demo up --build -d ;; restart) -sudo docker compose --env-file .env.demo up down -v +sudo docker compose --env-file .env.demo down -v sudo docker compose --env-file .env.demo up --build -d ;; stop) -sudo docker compose --env-file .env.demo up down -v +sudo docker compose --env-file .env.demo down -v ;; *) usage;; diff --git a/server/app/api.py b/server/app/api.py index 60ccaad..d847615 100644 --- a/server/app/api.py +++ b/server/app/api.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, APIRouter from fastapi.responses import JSONResponse -from app.routers import identifiers +from app.routers import identifiers, resolvers from config import settings app = FastAPI(title=settings.PROJECT_TITLE, version=settings.PROJECT_VERSION) @@ -12,7 +12,8 @@ async def server_status(): return JSONResponse(status_code=200, content={"status": "ok"}) -api_router.include_router(identifiers.router, tags=["Identifiers"]) +api_router.include_router(identifiers.router) +api_router.include_router(resolvers.router) diff --git a/server/app/models/did_document.py b/server/app/models/did_document.py index 4f66586..7f57e0b 100644 --- a/server/app/models/did_document.py +++ b/server/app/models/did_document.py @@ -1,4 +1,4 @@ -from typing import Union, List, Dict +from typing import Union, List, Dict, Any from pydantic import BaseModel, Field from .di_proof import DataIntegrityProof @@ -18,7 +18,10 @@ class Service(BaseModel): class DidDocument(BaseModel): context: Union[str, List[str]] = Field( - ["https://www.w3.org/ns/did/v1"], alias="@context" + [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1" + ], alias="@context" ) id: str = Field(None) controller: str = Field(None) diff --git a/server/app/models/web_requests.py b/server/app/models/web_requests.py index d97f639..9f93838 100644 --- a/server/app/models/web_requests.py +++ b/server/app/models/web_requests.py @@ -1,7 +1,29 @@ -from typing import Union, List, Dict +from typing import Union, List, Dict, Any from pydantic import BaseModel, Field from .did_document import DidDocument +from config import settings +class BaseRequest(BaseModel): + def model_dump(self, **kwargs) -> Dict[str, Any]: + return super().model_dump(by_alias=True, exclude_none=True, **kwargs) -class RegisterDID(BaseModel): - didDocument: DidDocument = Field() + +class RequestDID(BaseRequest): + namespace: str = Field(example="example") + identifier: str = Field(example="issuer") + + +class RegisterDID(BaseRequest): + didDocument: DidDocument = Field(example={ + "@context": [], + "id": f"{settings.DID_WEB_BASE}:example:issuer" + }) + + +class RequestDIDUpgrade(BaseRequest): + id: str = Field(example=f"{settings.DID_WEB_BASE}:example:issuer") + updateKey: str = Field(example="z...") + + +class PublishLogEntry(BaseRequest): + logEntry: list = Field() diff --git a/server/app/plugins/askar.py b/server/app/plugins/askar.py index e9adeb7..3ede940 100644 --- a/server/app/plugins/askar.py +++ b/server/app/plugins/askar.py @@ -3,7 +3,7 @@ from aries_askar import Store, error, Key from aries_askar.bindings import LocalKeyHandle from config import settings -from app.utilities import create_did_doc +from app.models.did_document import DidDocument import hashlib import uuid from multiformats import multibase @@ -21,9 +21,22 @@ def __init__(self): async def provision(self, recreate=False): await Store.provision(self.db, "raw", self.key, recreate=recreate) - endorser_did_doc = create_did_doc( - did=settings.DID_WEB_BASE, multikey=settings.ENDORSER_MULTIKEY - ) + did = settings.DID_WEB_BASE + kid = f'{settings.DID_WEB_BASE}#key-01' + endorser_did_doc = DidDocument( + id=did, + verificationMethod=[ + { + "id": kid, + "type": "Multikey", + "controller": did, + "publicKeyMultibase": settings.ENDORSER_MULTIKEY, + } + ], + authentication=[kid], + assertionMethod=[kid], + service=[], + ).dict(by_alias=True, exclude_none=True) try: await self.store("didDocument", settings.DID_WEB_BASE, endorser_did_doc) except: @@ -78,22 +91,26 @@ def __init__(self, multikey=None): alg="ed25519", public=bytes(bytearray(multibase.decode(multikey))[2:]) ) - def create_proof_config(self): + def create_proof_config(self, challenge=None): created = str(datetime.now(timezone.utc).isoformat("T", "seconds")) - expires = str( - (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat( - "T", "seconds" - ) - ) - return { + proof_options = { "type": self.type, "cryptosuite": self.cryptosuite, "proofPurpose": self.purpose, "created": created, - "expires": expires, "domain": settings.DOMAIN, - "challenge": self.create_challenge(created + expires), + "challenge": challenge, } + if not challenge: + expires = str( + (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat( + "T", "seconds" + ) + ) + proof_options['expires'] = expires + proof_options['challenge'] = self.create_challenge(created + expires) + + return proof_options def create_challenge(self, value): return str(uuid.uuid5(uuid.NAMESPACE_DNS, settings.SECRET_KEY + value)) diff --git a/server/app/plugins/trust_did_web.py b/server/app/plugins/trust_did_web.py index 901d070..db70cec 100644 --- a/server/app/plugins/trust_did_web.py +++ b/server/app/plugins/trust_did_web.py @@ -27,15 +27,15 @@ def _define_parameters(self, update_key=None, next_key=None, ttl=100): def _generate_entry_hash(self, log_entry): # https://identity.foundation/trustdidweb/#generate-entry-hash jcs = canonicaljson.encode_canonical_json(log_entry) - multihash = multihash.digest(jcs.encode(), 'sha2-256').hex() - encoded = multibase.encode(multihash, 'base58btc')[1:] + multihashed = multihash.digest(jcs, 'sha2-256') + encoded = multibase.encode(multihashed, 'base58btc')[1:] return encoded def _generate_scid(self, log_entry): # https://identity.foundation/trustdidweb/#generate-scid jcs = canonicaljson.encode_canonical_json(log_entry) - multihash = multihash.digest(jcs.encode(), 'sha2-256').hex() - encoded = multibase.encode(multihash, 'base58btc')[1:] + multihashed = multihash.digest(jcs, 'sha2-256') + encoded = multibase.encode(multihashed, 'base58btc')[1:] return encoded def _add_placeholder_scid(self, item): @@ -52,11 +52,11 @@ def _web_to_tdw(self, did_doc): for idx, item in enumerate(did_doc['verificationMethod']): did_doc['verificationMethod'][idx] = self._add_placeholder_scid(did_doc['verificationMethod'][idx]) - def _init_parameters(self): + def _init_parameters(self, update_key): return { "method": 'did:tdw:0.3', "scid": r"{SCID}", - "updateKeys": [], + "updateKeys": [update_key], "portable": False, "prerotation": False, "nextKeyHashes": [], @@ -68,16 +68,22 @@ def _init_did_doc(self): "@context": [], "id": r"{SCID}", } - def provision_log_entry(self, did_doc): - did_doc['id'] = did_doc['id'].replace('did:web:', r'did:tdw:{SCID}:') - return [ + + def provision_log_entry(self, did_doc, update_key): + did_doc = json.loads(json.dumps(did_doc).replace('did:web:', r'did:tdw:{SCID}:')) + preliminary_did_log_entry = [ r'{SCID}', str(datetime.now(timezone.utc).isoformat("T", "seconds")), - self._init_parameters(), + self._init_parameters(update_key=update_key), { "value": did_doc } ] + scid = self._generate_scid(preliminary_did_log_entry) + log_entry = json.loads(json.dumps(preliminary_did_log_entry).replace('{SCID}', scid)) + entry_hash = self._generate_entry_hash(log_entry) + log_entry[0] = f'1-{entry_hash}' + return log_entry def create(self, did_doc): # https://identity.foundation/trustdidweb/#create-register diff --git a/server/app/routers/identifiers.py b/server/app/routers/identifiers.py index 5490697..1fc9459 100644 --- a/server/app/routers/identifiers.py +++ b/server/app/routers/identifiers.py @@ -1,68 +1,70 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from fastapi.responses import JSONResponse -from app.models.web_requests import RegisterDID +from app.models.web_requests import RequestDID, RegisterDID, RequestDIDUpgrade, PublishLogEntry from config import settings from app.plugins import AskarVerifier, AskarStorage, TrustDidWeb -from app.dependencies import ( - identifier_available, - did_document_exists, - valid_did_registration, -) -from app.utilities import create_did_doc_template +# from app.dependencies import ( +# did_document_exists, +# valid_did_registration, +# ) +from app.utilities import location_available, to_did_web, valid_did_registration, did_document_exists, bootstrap_did_doc +import jsonlines +import json -router = APIRouter() +router = APIRouter(tags=["Identifiers"]) -@router.get("/{namespace}/{identifier}", summary="Request DID configuration.") -async def get_did( - namespace: str, identifier: str, logs: bool = False, dependency=Depends(identifier_available) -): - did_doc = create_did_doc_template(namespace, identifier) - if logs: - return JSONResponse( - status_code=200, - content={ - "logEntry": TrustDidWeb().provision_log_entry(did_doc), - "options": AskarVerifier().create_proof_config(), - }, - ) +@router.post("/did/request", summary="Request new identifier.") +async def request_did(request_body: RequestDID): + did = to_did_web(request_body.model_dump()['namespace'], request_body.model_dump()['identifier']) + await location_available(did) return JSONResponse( status_code=200, content={ - "document": did_doc, + "document": bootstrap_did_doc(did), "options": AskarVerifier().create_proof_config(), }, ) -@router.post("/{namespace}/{identifier}", 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) - return JSONResponse(status_code=201, content={"didDocument": did_document}) - - -@router.put("/{namespace}/{identifier}", summary="Update DID document.") -async def update_did( - namespace: str, identifier: str, dependency=Depends(did_document_exists) -): - raise HTTPException(status_code=501, detail="Not implemented.") +@router.post("/did/register", summary="Register identifier.") +async def upgrade_did(request_body: RegisterDID): + did_document = request_body.model_dump()['didDocument'] + await location_available(did_document['id']) + await valid_did_registration(did_document) + await AskarStorage().store("didDocument", did_document['id'], did_document) + return JSONResponse( + status_code=201, + content={ + "didDocument": did_document, + }, + ) -@router.delete("/{namespace}/{identifier}", summary="Archive DID.") -async def delete_did( - namespace: str, identifier: str, dependency=Depends(did_document_exists) -): - raise HTTPException(status_code=501, detail="Not implemented.") +@router.post("/did/upgrade", summary="Upgrade to Trust DID Web.") +async def request_did_upgrade(request_body: RequestDIDUpgrade): + await did_document_exists(request_body.model_dump()['id']) + did_document = await AskarStorage().fetch("didDocument", request_body.model_dump()['id']) + log_entry = TrustDidWeb().provision_log_entry(did_document, request_body.model_dump()['updateKey']) + return JSONResponse( + status_code=200, + content={ + "logEntry": log_entry, + "proofOptions": AskarVerifier().create_proof_config(challenge=log_entry[0]), + }, + ) -@router.get("/{namespace}/{identifier}/did.json", summary="Get DID document.") -async def get_did( - 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) +@router.post("/did/log", summary="Publish log entry.") +async def publish_log(request_body: PublishLogEntry, response: Response): + log_entry = request_body.model_dump()['logEntry'] + did_tdw = log_entry[3]['value']['id'] + did_web = 'did:web:'+':'.join(did_tdw.split(':')[3:]) + await did_document_exists(did_web) + # await valid_log_entry(log_entry) + did_document = await AskarStorage().fetch("didDocument", did_web) + did_document['alsoKnownAs'] = log_entry[3]['value']['id'] + did_document = await AskarStorage().update("didDocument", did_web, did_document) + logs = [json.dumps(log_entry)] + did_document = await AskarStorage().store("didLogs", did_web, logs) + return JSONResponse(status_code=201, content={'did': did_tdw}) diff --git a/server/app/routers/resolvers.py b/server/app/routers/resolvers.py new file mode 100644 index 0000000..98137ee --- /dev/null +++ b/server/app/routers/resolvers.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.responses import JSONResponse +from config import settings +from app.plugins import AskarVerifier, AskarStorage, TrustDidWeb +from app.utilities import to_did_web, did_document_exists +import jsonlines +import json + +router = APIRouter(tags=["Resolvers"]) + +@router.get("/.well-known/did.json") +async def get_endorser_did(): + did_document = await AskarStorage().fetch("didDocument", settings.DID_WEB_BASE) + return JSONResponse(status_code=200, content=did_document) + +@router.get("/{namespace}/{identifier}/did.json") +async def get_did( + namespace: str, identifier: str +): + did = to_did_web(namespace, identifier) + await did_document_exists(did) + did_document = await AskarStorage().fetch("didDocument", did) + return JSONResponse(status_code=200, content=did_document) + + +@router.get("/{namespace}/{identifier}/did.jsonl") +async def get_did_logs( + namespace: str, identifier: str, response: Response +): + did = to_did_web(namespace, identifier) + await did_document_exists(did) + did_logs = await AskarStorage().fetch("didLogs", did) + did_logs = jsonlines.Reader(did_logs).read() + response.headers['Content-Type'] = 'application/octet-stream' + return did_logs \ No newline at end of file diff --git a/server/app/routers/tdw.py b/server/app/routers/tdw.py new file mode 100644 index 0000000..1f629ca --- /dev/null +++ b/server/app/routers/tdw.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from app.models.web_requests import RegisterDID +from config import settings +from app.plugins import AskarVerifier, AskarStorage, TrustDidWeb +from app.dependencies import ( + identifier_available, + did_document_exists, + valid_did_registration, +) +from app.utilities import create_did_doc_template + +router = APIRouter() \ No newline at end of file diff --git a/server/app/utilities.py b/server/app/utilities.py index 3bfa18a..bfb5fb5 100644 --- a/server/app/utilities.py +++ b/server/app/utilities.py @@ -1,5 +1,55 @@ from app.models.did_document import DidDocument from config import settings +from app.plugins import AskarVerifier, AskarStorage +from fastapi import HTTPException + + +def to_did_web(namespace: str, identifier: str): + return f'{settings.DID_WEB_BASE}:{namespace}:{identifier}' + + +async def location_available(did: str): + if await AskarStorage().fetch("didDocument", did): + raise HTTPException(status_code=409, detail="Identifier unavailable.") + + +async def did_document_exists(did: str): + if not await AskarStorage().fetch("didDocument", did): + raise HTTPException(status_code=404, detail="Ressource not found.") + + +async def valid_did_registration( + did_document +): + did_document + proofs = did_document.pop("proof") + try: + # assert ( + # did_document["id"] == f"{settings.DID_WEB_BASE}:{namespace}:{identifier}" + # ), "Id mismatch between DID Document and requested endpoint." + assert ( + len(did_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(did_document, endorser_proof) + + client_proof = find_proof(proofs, did_document["verificationMethod"][0]["id"]) + client_key = find_key(did_document, client_proof["verificationMethod"]) + AskarVerifier(client_key).verify_proof(did_document, client_proof) + + return did_document + + +async def identifier_available(identifier: str): + if await AskarStorage().fetch("didDocument", identifier): + raise HTTPException(status_code=409, detail="Identifier unavailable.") def create_did_doc(did, multikey, kid='key-01'): return DidDocument( @@ -17,9 +67,9 @@ def create_did_doc(did, multikey, kid='key-01'): service=[], ).dict(by_alias=True, exclude_none=True) -def create_did_doc_template(namespace, identifier): +def bootstrap_did_doc(did): return DidDocument( - id=f"{settings.DID_WEB_BASE}:{namespace}:{identifier}", + id=did, ).dict(by_alias=True, exclude_none=True) diff --git a/server/requirements.txt b/server/requirements.txt index 46c0970..2578171 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,6 +1,7 @@ annotated-types==0.7.0 anyio==4.4.0 aries-askar==0.3.2 +attrs==24.2.0 base58==2.1.1 bases==0.3.0 black==24.8.0 @@ -13,6 +14,7 @@ fastapi==0.112.0 h11==0.14.0 idna==3.7 inflection==0.5.1 +jsonlines==4.0.0 multiformats==0.3.1.post4 multiformats-config==0.3.1 mypy-extensions==1.0.0