Skip to content

Commit 50efc1e

Browse files
authored
Merge pull request #129 from minvws/rid
Implemented RID exchange and receive
2 parents 5df80d2 + 36f6a22 commit 50efc1e

23 files changed

Lines changed: 787 additions & 158 deletions

.github/workflows/ci.yaml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,20 @@ jobs:
9999
name: Run the tests
100100
runs-on: ubuntu-24.04
101101
needs: build
102+
services:
103+
prs_db:
104+
image: postgres:15
105+
env:
106+
POSTGRES_USER: postgres
107+
POSTGRES_PASSWORD: postgres
108+
POSTGRES_DB: testing
109+
ports:
110+
- 5432:5432
111+
options: >-
112+
--health-cmd pg_isready
113+
--health-interval 10s
114+
--health-timeout 5s
115+
--health-retries 5
102116
103117
steps:
104118
- name: Checkout repository
@@ -109,8 +123,25 @@ jobs:
109123
with:
110124
python_version: "3.11"
111125

126+
- name: install dependencies
127+
run: |
128+
sudo apt update
129+
sudo apt install -y libpq-dev build-essential postgresql-client
130+
131+
- name: install liboprf
132+
run: |
133+
sudo apt install -y libsodium-dev
134+
git clone https://github.com/stef/liboprf.git
135+
cd liboprf/src
136+
make
137+
sudo make install
138+
sudo ldconfig
139+
112140
- name: Run the tests
113-
run: poetry run $(make test --just-print --silent)
141+
run: |
142+
sudo bash -c 'echo "127.0.0.1 prs_db" >> /etc/hosts'
143+
./tools/migrate_db.sh prs_db postgres postgres testing
144+
poetry run $(make test --just-print --silent)
114145
115146
- name: Upload coverage report
116147
uses: actions/upload-artifact@v4

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type-check: ## Check for typing errors
2626
$(RUN_PREFIX) mypy app tests
2727

2828
safety-check: ## Check for security vulnerabilities
29-
$(RUN_PREFIX) pip-audit
29+
$(RUN_PREFIX) pip-audit --ignore-vuln GHSA-4xh5-x5gv-qwph
3030

3131
spelling-check: ## Check spelling mistakes
3232
$(RUN_PREFIX) codespell .

app.conf.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,5 @@ server_key_file=
9292
hmac_key=
9393
; AES key for pseudonym encryption
9494
aes_key=
95+
; AES key for rid encryption
96+
rid_aes_key=

app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ class ConfigOprf(BaseModel):
136136
class ConfigPseudonym(BaseModel):
137137
hmac_key: str | None = Field(default=None)
138138
aes_key: str | None = Field(default=None)
139+
rid_aes_key: str | None = Field(default=None)
139140

140141

141142
class Config(BaseModel):

app/container.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from app.services.rid_cache import RidCache
2222
from app.services.rid_service import RidService
2323
from app.services.tls_service import TLSService
24+
from app.services.tmp_rid_service import TmpRidService
2425

2526

2627
def container_config(binder: inject.Binder) -> None:
@@ -96,6 +97,14 @@ def container_config(binder: inject.Binder) -> None:
9697
)
9798
binder.bind(PseudonymService, pseudonym_service)
9899

100+
tmp_rid_service = TmpRidService(
101+
base64.urlsafe_b64decode(config.pseudonym.rid_aes_key or ""),
102+
)
103+
binder.bind(TmpRidService, tmp_rid_service)
104+
105+
106+
def get_tmp_rid_service() -> TmpRidService:
107+
return inject.instance(TmpRidService)
99108

100109
def get_pseudonym_service() -> PseudonymService:
101110
return inject.instance(PseudonymService)

app/db/entities/key_entry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ class KeyEntry(Base):
1414
organization = Column(String, nullable=False)
1515
scope = Column(JSONB, nullable=False, server_default="{}")
1616
key = Column(Text, nullable=False)
17+
max_rid_usage = Column(String, nullable=False)
1718

1819
def to_dict(self) -> dict[str, Any]:
1920
return {
2021
"key_id": str(self.entry_id),
2122
"organization": self.organization,
2223
"scope": self.scope,
2324
"pub_key": self.key,
25+
"max_rid_usage": self.max_rid_usage,
2426
}

app/db/repositories/key_entry_repository.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,22 @@ def get_by_org(self, organization: str) -> Optional[Sequence[KeyEntry]]:
4242
query = select(KeyEntry).where(KeyEntry.organization == organization)
4343
return self.db_session.session.execute(query).scalars().all()
4444

45-
def create(self, organization: str, scope: list[str], pub_key: str) -> KeyEntry:
45+
def create(self, organization: str, scope: list[str], pub_key: str, max_usage_level: str) -> KeyEntry:
4646
"""
4747
Creates a new key entry.
4848
"""
4949
entry = KeyEntry(
5050
organization=organization,
5151
scope=scope,
5252
key=pub_key,
53+
max_rid_usage=max_usage_level,
5354
)
5455
self.db_session.session.add(entry)
56+
self.db_session.session.flush()
5557

5658
return entry
5759

58-
def update(self, entry_id: str, scope: list[str], pub_key: str) -> Optional[KeyEntry]:
60+
def update(self, entry_id: str, scope: list[str], pub_key: str, max_usage_level: str) -> Optional[KeyEntry]:
5961
"""
6062
Updates an existing key entry.
6163
"""
@@ -65,5 +67,6 @@ def update(self, entry_id: str, scope: list[str], pub_key: str) -> Optional[KeyE
6567

6668
entry.scope = scope # type: ignore
6769
entry.key = pub_key # type: ignore
70+
entry.max_rid_usage = max_usage_level # type: ignore
6871
self.db_session.session.add(entry)
6972
return entry

app/models/requests.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
1-
from typing import Any
1+
from typing import Any, Literal
22

33
from pydantic import BaseModel, ConfigDict, model_validator
44

55
from app.personal_id import PersonalId
66
from app.services.pseudonym_service import PseudonymType
7+
from app.rid import RidUsage
8+
9+
class RidReceiveRequest(BaseModel):
10+
rid: str
11+
recipientOrganization: str
12+
recipientScope: str
13+
pseudonymType: Literal['rp', 'irp', 'bsn']
14+
15+
class RidExchangeRequest(BaseModel):
16+
personalId: Any
17+
recipientOrganization: str
18+
recipientScope: str
19+
ridUsage: Any
20+
21+
@model_validator(mode="before")
22+
@classmethod
23+
def convert_personal_id(cls, data: dict[str, Any]) -> dict[str, Any]:
24+
pid = data.get("personalId")
25+
if isinstance(pid, str):
26+
data["personalId"] = PersonalId.from_str(pid)
27+
if isinstance(pid, dict):
28+
data["personalId"] = PersonalId.from_dict(pid)
29+
30+
ridUsage = data.get("ridUsage")
31+
if isinstance(ridUsage, str):
32+
data["ridUsage"] = RidUsage(ridUsage)
33+
34+
return data
735

836

937
class ExchangeRequest(BaseModel):

app/rid.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from enum import Enum
2+
from typing import Dict, Set
3+
4+
# Define which pseudonym types are allowed for each RID usage
5+
ALLOWED_BY_RID_USAGE: Dict[str, Set[str]] = {
6+
"bsn": {"bsn", "rp", "irp"},
7+
"rp": {"rp", "irp"},
8+
"irp": {"irp"},
9+
}
10+
11+
# Define the minimum RID usage required for each pseudonym type
12+
REQUIRED_MIN_USAGE = {
13+
"bsn": "Bsn",
14+
"rp": "ReversiblePseudonym",
15+
"irp": "IrreversiblePseudonym",
16+
}
17+
18+
# Mapping of RID usage to their rank (higher number can exchange lowr number)
19+
USAGE_RANK = {
20+
"IrreversiblePseudonym": 1,
21+
"ReversiblePseudonym": 2,
22+
"Bsn": 3,
23+
}
24+
25+
class RidUsage(str, Enum):
26+
Bsn = "bsn"
27+
ReversiblePseudonym = "rp"
28+
IrreversiblePseudonym = "irp"
29+
30+
def __str__(self) -> str:
31+
return self.value

app/routers/exchange.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,139 @@
1+
import json
12
import logging
3+
from typing import Dict, Any
4+
25
from fastapi import APIRouter, Depends, HTTPException
36
from starlette.responses import JSONResponse, Response
47

58
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
711
from app.services.key_resolver import KeyResolver
812
from app.services.oprf.jwe_token import BlindJwe
913
from app.services.pseudonym_service import PseudonymService, PseudonymType
14+
from app.services.tmp_rid_service import TmpRidService
1015

1116
logger = logging.getLogger(__name__)
1217
router = APIRouter()
1318

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+
14137
@router.post("/exchange/pseudonym", summary="Exchange pseudonym")
15138
def exchange_pseudonym(
16139
req: ExchangeRequest,

0 commit comments

Comments
 (0)