Skip to content

Commit 18fa51e

Browse files
authored
Merge pull request #332 from minvws/total-cleanup
Bit of code cleanup
2 parents 1356178 + 80e553e commit 18fa51e

23 files changed

Lines changed: 384 additions & 199 deletions

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,13 @@ __pycache__
22
.venv
33
secrets/ssl/*
44
secrets/*
5+
tests/secrets/
56
app.conf
67
auth_cert.json
8+
9+
# Test coverage artifacts (generated by `make test`)
10+
.coverage
11+
coverage.xml
12+
13+
# Certificates / keys (never commit secrets)
14+
*.pem

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ The code examples are only meant to help understand concepts and demonstrate pos
2929
By using or referencing this code, you acknowledge that you do so at your own
3030
risk and that the authors assume no liability for any consequences of its use.
3131

32+
## Security & trust model
33+
34+
The PRS does not authenticate callers itself. Caller identity is verified by a
35+
separate upstream system (the OIN-verifier) that injects trusted headers, which
36+
the PRS trusts as-is. This means the PRS must never be reachable without that
37+
proxy in front of it. See [docs/trust-model.md](docs/trust-model.md) for the
38+
full trust model, the headers involved, and the deployment invariants that must
39+
hold.
40+
3241
## Development setup
3342

3443
This project can be setup and tested either as a python application directly on an operating system or in a Docker

app/auth.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,6 @@
1111
logger = logging.getLogger(__name__)
1212

1313

14-
class OAuthError(Exception):
15-
"""
16-
Raised for general OAuth2 errors.
17-
"""
18-
19-
def __init__(self, code: str, description: str, status_code: int = 400):
20-
super().__init__(description)
21-
self.code = code
22-
self.description = description
23-
self.status_code = status_code
24-
25-
2614
def get_auth_ctx(
2715
request: Request,
2816
auth_headers_service: AuthHeaderService = Depends(

app/config.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
from typing import Any, List
55

6-
from pydantic import BaseModel, ValidationError, field_validator
6+
from pydantic import BaseModel, field_validator
77
from pydantic import Field
88

99
_PATH = "app.conf"
@@ -159,18 +159,15 @@ def get_config(path: str | None = None) -> Config:
159159
# a standard format for pydantic, we need to do some manual parsing first.
160160
ini_data = read_ini_file(path)
161161

162-
try:
163-
# Convert database.retry_backoff to a list of floats
164-
if "retry_backoff" in ini_data["database"] and isinstance(
165-
ini_data["database"]["retry_backoff"], str
166-
):
167-
# convert the string to a list of floats
168-
ini_data["database"]["retry_backoff"] = [
169-
float(i) for i in ini_data["database"]["retry_backoff"].split(",")
170-
]
171-
172-
_CONFIG = Config.model_validate(ini_data)
173-
except ValidationError as e:
174-
raise e
162+
# Convert database.retry_backoff to a list of floats
163+
if "retry_backoff" in ini_data["database"] and isinstance(
164+
ini_data["database"]["retry_backoff"], str
165+
):
166+
# convert the string to a list of floats
167+
ini_data["database"]["retry_backoff"] = [
168+
float(i) for i in ini_data["database"]["retry_backoff"].split(",")
169+
]
170+
171+
_CONFIG = Config.model_validate(ini_data)
175172

176173
return _CONFIG

app/container.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import logging
23

34
import inject
45

@@ -14,15 +15,49 @@
1415
from app.services.org_service import OrgService
1516
from app.services.pseudonym_service import PseudonymService
1617
from app.services.rid_service import RidService
17-
import logging
1818

1919
logger = logging.getLogger(__name__)
2020

21+
# Minimum master key size in bytes. The master key must have at least as much
22+
# entropy as the 256-bit keys HKDF derives from it.
23+
_MIN_MASTER_KEY_BYTES = 32
24+
25+
26+
def _load_master_key(raw: str) -> bytes:
27+
"""
28+
Decode and validate the configured pseudonym master key.
29+
30+
All pseudonym, reversible-pseudonym and RID keys are derived from this key,
31+
so an empty or weak master key would make every pseudonym forgeable and every
32+
reversible pseudonym/RID decryptable. Refuse to start rather than silently
33+
run with a b"" key.
34+
"""
35+
if not raw:
36+
raise ValueError(
37+
"pseudonym.master_key is not configured. Set a base64-encoded key of "
38+
"at least 32 bytes, e.g. `openssl rand -base64 32`."
39+
)
40+
41+
key = base64.urlsafe_b64decode(raw)
42+
if len(key) < _MIN_MASTER_KEY_BYTES:
43+
raise ValueError(
44+
f"pseudonym.master_key is too short ({len(key)} bytes decoded); "
45+
f"at least {_MIN_MASTER_KEY_BYTES} bytes are required."
46+
)
47+
48+
return key
49+
2150

2251
def container_config(binder: inject.Binder) -> None:
2352
config = get_config()
2453

25-
db = Database(dsn=config.database.dsn)
54+
db = Database(
55+
dsn=config.database.dsn,
56+
pool_size=config.database.pool_size,
57+
max_overflow=config.database.max_overflow,
58+
pool_pre_ping=config.database.pool_pre_ping,
59+
pool_recycle=config.database.pool_recycle,
60+
)
2661
binder.bind(Database, db)
2762

2863
key_resolver = KeyResolver(db)
@@ -66,17 +101,13 @@ def container_config(binder: inject.Binder) -> None:
66101
oprf_service = OprfService(server_key=key)
67102
binder.bind(OprfService, oprf_service)
68103

69-
pseudonym_service = PseudonymService(
70-
# This should be done through an HSM
71-
base64.urlsafe_b64decode(config.pseudonym.master_key or ""),
72-
)
104+
# This should be done through an HSM
105+
master_key = _load_master_key(config.pseudonym.master_key)
106+
107+
pseudonym_service = PseudonymService(master_key)
73108
binder.bind(PseudonymService, pseudonym_service)
74109

75-
rid_service = RidService(
76-
# This should be done through an HSM
77-
base64.urlsafe_b64decode(config.pseudonym.master_key or ""),
78-
b"RID:v1",
79-
)
110+
rid_service = RidService(master_key, b"RID:v1")
80111
binder.bind(RidService, rid_service)
81112

82113

app/data.py

Lines changed: 0 additions & 18 deletions
This file was deleted.

app/db/db.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010

1111

1212
class Database:
13-
def __init__(self, dsn: str):
13+
def __init__(
14+
self,
15+
dsn: str,
16+
pool_size: int = 5,
17+
max_overflow: int = 10,
18+
pool_pre_ping: bool = False,
19+
pool_recycle: int = 3600,
20+
):
1421
try:
1522
if "sqlite://" in dsn:
1623
self.engine = create_engine(
@@ -21,7 +28,14 @@ def __init__(self, dsn: str):
2128
echo=False,
2229
)
2330
else:
24-
self.engine = create_engine(dsn, echo=False)
31+
self.engine = create_engine(
32+
dsn,
33+
echo=False,
34+
pool_size=pool_size,
35+
max_overflow=max_overflow,
36+
pool_pre_ping=pool_pre_ping,
37+
pool_recycle=pool_recycle,
38+
)
2539
except BaseException as e:
2640
logger.exception("error while connecting to database")
2741
raise e

app/db/repositories/org_key_repository.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,3 @@ def update(
7676
entry.key_data = key_data
7777
self.db_session.add(entry)
7878
return entry
79-
80-
def delete_by_org(self, org_id: uuid.UUID) -> int:
81-
"""
82-
Deletes all key entries for a given organization.
83-
"""
84-
query = select(OrganizationKey).where(OrganizationKey.organization_id == org_id)
85-
entries = self.db_session.execute(query).scalars().all()
86-
count = len(entries)
87-
88-
for entry in entries:
89-
self.db_session.delete(entry)
90-
91-
return count
Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
import uuid
32
from typing import Optional
43

54
from app.db.decorator import repository
@@ -35,38 +34,3 @@ def create(self, oin: Oin, name: str, max_usage_level: str) -> Organization:
3534

3635
logger.info("created organization with OIN %s and name %r", oin.value, name)
3736
return entry
38-
39-
def update(
40-
self, org_id: uuid.UUID, name: str, max_usage_level: str
41-
) -> Optional[Organization]:
42-
"""
43-
Updates an existing key entry.
44-
"""
45-
org = self.get_by_id(org_id)
46-
if org is None:
47-
logger.error(f"organization with ID {org_id} does not exist")
48-
raise ValueError("Organization not found")
49-
50-
org.name = name
51-
org.max_rid_usage = max_usage_level
52-
self.db_session.add(org)
53-
return org
54-
55-
def delete(self, org_id: uuid.UUID) -> bool:
56-
"""
57-
Deletes an organization by its unique ID.
58-
"""
59-
org = self.get_by_id(org_id)
60-
if org is None:
61-
logger.error("organization with ID %s does not exist", org_id)
62-
return False
63-
64-
self.db_session.delete(org)
65-
return True
66-
67-
def get_by_id(self, org_id: uuid.UUID) -> Optional[Organization]:
68-
"""
69-
Fetches the organization by its unique ID.
70-
"""
71-
query = select(Organization).where(Organization.id == org_id)
72-
return self.db_session.execute(query).scalars().first()

app/models/requests.py

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,6 @@ class RegisterRequest(BaseModel):
1818
key_id: str | None
1919

2020

21-
class OrgRequest(BaseModel):
22-
oin: Oin
23-
name: str = Field(..., min_length=5, max_length=50)
24-
max_key_usage: RidUsage
25-
26-
2721
class HsmKeyVersionRequest(BaseModel):
2822
oin: Oin
2923
from_dt: datetime | None = None
@@ -120,8 +114,7 @@ def convert_personal_id(cls, data: dict[str, Any]) -> dict[str, Any]:
120114
return data
121115

122116

123-
class ReceiverRequest(BaseModel):
124-
blind_factor: str
117+
class JweReceiverRequest(BaseModel):
125118
jwe: str
126119
priv_key_pem: str
127120

@@ -147,27 +140,5 @@ def validate_priv_key_pem(cls, data: dict[str, Any]) -> dict[str, Any]:
147140
return data
148141

149142

150-
class JweReceiverRequest(BaseModel):
151-
jwe: str
152-
priv_key_pem: str
153-
154-
@model_validator(mode="before")
155-
@classmethod
156-
def validate_jwe(cls, data: dict[str, Any]) -> dict[str, Any]:
157-
# Check if JWE is actually a jwe token
158-
jwe_token = data.get("jwe")
159-
if not isinstance(jwe_token, str) or len(jwe_token.split(".")) != 5:
160-
logger.warning("invalid JWE token format: %s", jwe_token)
161-
raise ValueError("Invalid JWE token")
162-
return data
163-
164-
@model_validator(mode="before")
165-
@classmethod
166-
def validate_priv_key_pem(cls, data: dict[str, Any]) -> dict[str, Any]:
167-
priv_key_pem = data.get("priv_key_pem")
168-
if not isinstance(priv_key_pem, str) or not priv_key_pem.startswith(
169-
"-----BEGIN PRIVATE KEY-----"
170-
):
171-
logger.warning("invalid private key PEM format")
172-
raise ValueError("Invalid private key PEM format")
173-
return data
143+
class ReceiverRequest(JweReceiverRequest):
144+
blind_factor: str

0 commit comments

Comments
 (0)