|
| 1 | +import json |
1 | 2 | import logging |
| 3 | +from typing import Dict, Any |
| 4 | + |
2 | 5 | from fastapi import APIRouter, Depends, HTTPException |
3 | 6 | from starlette.responses import JSONResponse, Response |
4 | 7 |
|
5 | 8 | from app import container |
6 | | -from app.models.requests import ExchangeRequest |
| 9 | +from app.models.requests import ExchangeRequest, RidExchangeRequest, RidReceiveRequest |
| 10 | +from app.rid import ALLOWED_BY_RID_USAGE, REQUIRED_MIN_USAGE, USAGE_RANK |
7 | 11 | from app.services.key_resolver import KeyResolver |
8 | 12 | from app.services.oprf.jwe_token import BlindJwe |
9 | 13 | from app.services.pseudonym_service import PseudonymService, PseudonymType |
| 14 | +from app.services.tmp_rid_service import TmpRidService |
10 | 15 |
|
11 | 16 | logger = logging.getLogger(__name__) |
12 | 17 | router = APIRouter() |
13 | 18 |
|
| 19 | + |
| 20 | +@router.post("/receive", summary="Receive and decrypt RID") |
| 21 | +def receive( |
| 22 | + req: RidReceiveRequest, |
| 23 | + key_resolver: KeyResolver = Depends(container.get_key_resolver), |
| 24 | + rid_service: TmpRidService = Depends(container.get_tmp_rid_service), |
| 25 | +) -> Response: |
| 26 | + """ |
| 27 | + Receive and decrypt a RID, validate it, and return a pseudonym of the requested type if allowed. |
| 28 | + """ |
| 29 | + if not req.rid.startswith("rid:"): |
| 30 | + raise HTTPException(status_code=400, detail="Invalid RID format") |
| 31 | + rid = req.rid.removeprefix("rid:") |
| 32 | + |
| 33 | + try: |
| 34 | + plaintext = rid_service.decrypt_rid(rid) |
| 35 | + if not plaintext: |
| 36 | + raise Exception("Empty plaintext") |
| 37 | + except Exception: |
| 38 | + raise HTTPException(status_code=400, detail="Failed to decrypt RID") |
| 39 | + |
| 40 | + |
| 41 | + try: |
| 42 | + payload: Dict[str, Any] = json.loads(plaintext) |
| 43 | + except json.JSONDecodeError: |
| 44 | + raise HTTPException(status_code=400, detail="Malformed RID payload") |
| 45 | + |
| 46 | + recipient_org = payload.get("recipient_organization") |
| 47 | + recipient_scope = payload.get("recipient_scope") |
| 48 | + rid_usage = payload.get("usage") |
| 49 | + |
| 50 | + # Make sure the recipient org/scope matches what is in the RID |
| 51 | + if recipient_org != req.recipientOrganization or recipient_scope != req.recipientScope: |
| 52 | + raise HTTPException( |
| 53 | + status_code=400, |
| 54 | + detail="RID not intended for this organization and/or scope", |
| 55 | + ) |
| 56 | + |
| 57 | + # Make sure we have got the correct permissions to exchange the requested pseudonym type |
| 58 | + if rid_usage not in ALLOWED_BY_RID_USAGE: |
| 59 | + raise HTTPException(status_code=400, detail="Unsupported RID usage") |
| 60 | + |
| 61 | + if req.pseudonymType not in ALLOWED_BY_RID_USAGE[rid_usage]: |
| 62 | + raise HTTPException( |
| 63 | + status_code=400, |
| 64 | + detail="Requested pseudonym type not allowed for this RID", |
| 65 | + ) |
| 66 | + |
| 67 | + max_rid_usage = key_resolver.max_rid_usage(req.recipientOrganization, req.recipientScope) |
| 68 | + if max_rid_usage is None: |
| 69 | + raise HTTPException( |
| 70 | + status_code=400, |
| 71 | + detail="Organization / scope is not allowed to exchange RIDs", |
| 72 | + ) |
| 73 | + |
| 74 | + required = REQUIRED_MIN_USAGE.get(req.pseudonymType) |
| 75 | + if required is None: |
| 76 | + raise HTTPException(status_code=400, detail="Unsupported pseudonym type") |
| 77 | + |
| 78 | + if USAGE_RANK.get(max_rid_usage.name, 0) < USAGE_RANK[required]: |
| 79 | + msg = { |
| 80 | + "bsn": "BSNs", |
| 81 | + "rp": "reversible pseudonyms or higher", |
| 82 | + "irp": "irreversible pseudonyms or higher", |
| 83 | + }[req.pseudonymType] |
| 84 | + raise HTTPException( |
| 85 | + status_code=400, |
| 86 | + detail=f"Organization / scope is not allowed to exchange {msg}", |
| 87 | + ) |
| 88 | + |
| 89 | + # TODO: Here we would generate the actual pseudonyms |
| 90 | + if req.pseudonymType == "bsn": |
| 91 | + value = "bsn:foobar" |
| 92 | + elif req.pseudonymType == "rp": |
| 93 | + value = "pseudonym:reversible:foobar" |
| 94 | + else: |
| 95 | + value = "pseudonym:irreversible:foobar" |
| 96 | + |
| 97 | + return JSONResponse(content={"pseudonym": value, "type": req.pseudonymType}) |
| 98 | + |
| 99 | + |
| 100 | +@router.post("/exchange/rid", summary="Exchange RID") |
| 101 | +def exchange_rid( |
| 102 | + req: RidExchangeRequest, |
| 103 | + key_resolver: KeyResolver = Depends(container.get_key_resolver), |
| 104 | + rid_service: TmpRidService = Depends(container.get_tmp_rid_service), |
| 105 | +) -> Response: |
| 106 | + """ |
| 107 | + Exchange a personal ID for a RID that can be used by the recipient organization/scope |
| 108 | + """ |
| 109 | + |
| 110 | + rid_data = { |
| 111 | + "usage": str(req.ridUsage), # Maximum usage allowed for this RID (capped by the recipient org/scope) |
| 112 | + "recipient_organization": req.recipientOrganization, |
| 113 | + "recipient_scope": req.recipientScope, |
| 114 | + "personal_id": req.personalId.as_str(), |
| 115 | + } |
| 116 | + rid_str = json.dumps(rid_data) |
| 117 | + rid = rid_service.encrypt_rid(rid_str) |
| 118 | + |
| 119 | + pub_key_jwk = key_resolver.resolve(req.recipientOrganization, req.recipientScope) |
| 120 | + if pub_key_jwk is None: |
| 121 | + return JSONResponse({"error": "No public key found for this organization and/or scope"}, status_code=404) |
| 122 | + |
| 123 | + # Create a blind JWE token containing the RID |
| 124 | + jwe = BlindJwe.build( |
| 125 | + audience=req.recipientOrganization, |
| 126 | + scope=req.recipientScope, |
| 127 | + subject=f"rid:{rid}", |
| 128 | + pub_key=pub_key_jwk, |
| 129 | + extra_claims={ |
| 130 | + "ridUsage": rid_data['usage'], |
| 131 | + } |
| 132 | + ) |
| 133 | + |
| 134 | + return Response(status_code=201, content=jwe, headers={"Content-Type": "Multipart/Encrypted"}) |
| 135 | + |
| 136 | + |
14 | 137 | @router.post("/exchange/pseudonym", summary="Exchange pseudonym") |
15 | 138 | def exchange_pseudonym( |
16 | 139 | req: ExchangeRequest, |
|
0 commit comments