Skip to content

Commit 60635c4

Browse files
authored
Merge pull request #123 from minvws/test-oprf
Added test endpoints for OPRF
2 parents cb818c6 + 111367a commit 60635c4

7 files changed

Lines changed: 260 additions & 3 deletions

File tree

README.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,124 @@ docker run -ti --rm -p 6502:6502 \
9595
--mount type=bind,source=./secrets,target=/src/secrets \
9696
gfmodules-prs
9797
```
98+
99+
100+
101+
# OPRF Testing
102+
103+
This system uses OPRF for pseudonym generation. To test this, there are some available endpoints:
104+
105+
- '/test/oprf/gen_rsa_key' - Generate a new RSA keypair for OPRF (small 1024 bit for testing)
106+
- '/test/oprf/oprf/client' - Emulates a client that generates OPRF information for a given input
107+
- '/test/oprf/oprf/receiver' - Emulates the receiver of the pseudonym and returns diagnostic information
108+
109+
To use this system:
110+
111+
1. First, generate a new keypair that will be used for a test organization with `/test/oprf/gen_rsa_key`.
112+
```shell
113+
POST /test/oprf/gen_rsa_key
114+
115+
200 OK
116+
{
117+
"private_key_pem": "-----BEGIN PRIVATE KEY-----\nMIICdwIBA....neDKJ0DsvA5vfpt0=\n-----END PRIVATE KEY-----\n",
118+
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqG....uDQIDAQAB\n-----END PUBLIC KEY-----\n",
119+
"public_key_kid": "ajGAx_LKNrJ8vqdahWlSJvOznBi6lnFfSOw72Z4R4uU"
120+
}
121+
```
122+
123+
2. Create a new organization into the key resolver with a POST to `/keys`. Add your organization name like
124+
`ura:12345678`, and you can use scope `test` for testing. Add the **PUBLIC key** to the `public_key` field.
125+
126+
```shell
127+
POST /keys
128+
{
129+
"organization": "ura:12345678",
130+
"scope": "test",
131+
"public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqG....uDQIDAQAB\n-----END PUBLIC KEY\n",
132+
}
133+
134+
201 Created
135+
```
136+
137+
3. Emulate a client wanting to send a pseudonym over to a receiver by calling `/test/oprf/client` with a JSON body like:
138+
139+
```shell
140+
POST /test/oprf/client
141+
{
142+
"personalId": "testinput-like-a-bsn-number-or-other-id"
143+
}
144+
145+
200 OK
146+
{
147+
"blinded_input": "EJU9qVhKNmw_UhCXDN_aVM4GL1DCmpDs8QD5WOdUBCU=",
148+
"blind_factor": "eNf80WNHbImaUNU-kokBr7ocELBjMtHcy0re_RKBPQ8="
149+
}
150+
```
151+
152+
This returns the `blinded_input` that must be sent to the receiver, and the `blind_factor` that must be send to the
153+
receiver after the server has evaluated the blinded input.
154+
155+
4. Now we can call the "real" OPRF function `/oprf/eval` with the blinded input, the organization name and scope:
156+
157+
```shell
158+
POST /oprf/eval
159+
{
160+
"encryptedPersonalId": "EJU9qVhKNmw_UhCXDN_aVM4GL1DCmpDs8QD5WOdUBCU=",
161+
"recipientOrganization": "ura:12345678",
162+
"recipientScope": "test"
163+
}
164+
165+
200 OK
166+
{
167+
"jwe": "eyJraWQiOi....bJUqbbSUIjqiw"
168+
}
169+
```
170+
171+
At this point we will get back a JWE that contains the evaluated blinded input and
172+
is encrypted with the public key of the organization. At this point, the client is
173+
not able to decrypt this information. It can only forward this to the receiver.
174+
175+
5. Now emulate the receiver by calling `/test/oprf/receiver` with a JSON body like:
176+
```shell
177+
POST /test/oprf/receiver
178+
{
179+
"blind_factor": "eNf80WNHbImaUNU-kokBr7ocELBjMtHcy0re_RKBPQ8=",
180+
"jwe": "eyJraWQiOiA...SzZbJUqbbSUIjqiw",
181+
"priv_key_pem": "-----BEGIN RSA PRIVATE KEY-----\nMIICXAIB...oCfe0=\n-----END RSA PRIVATE KEY-----\n"
182+
}
183+
```
184+
185+
The blind factor is the one returned by the client, the JWE is the one returned by the prs evaluation, and
186+
the private key is returned by the key generation step.
187+
188+
At this point, it will return any diagnostic information about the OPRF process:
189+
190+
```json
191+
{
192+
"jwe_data": "eyJraWQiOiAi...zZbJUqbbSUIjqiw",
193+
"priv_key_pem": "-----BEGIN RSA PRIVATE KEY-----\nMIICXAIBAAKBgH6gmpXpdhtiE...UpWRvoCfe0=\n-----END RSA PRIVATE KEY-----\n",
194+
"priv_key_kid": "rNv1O_mXgxF6QEMfaQGvjev7RbT1FG3sJXxxsu_KHbM",
195+
"blind_factor": "eNf80WNHbImaUNU-kokBr7ocELBjMtHcy0re_RKBPQ8=",
196+
"jwe": {
197+
"headers": {
198+
"kid": "rNv1O_mXgxF6QEMfaQGvjev7RbT1FG3sJXxxsu_KHbM",
199+
"alg": "RSA-OAEP-256",
200+
"enc": "A256GCM",
201+
"cty": "application/json"
202+
},
203+
"decrypted": {
204+
"subject": "pseudonym:eval:-Jpsoeik2058ip20b9Wd-vlwpjkjxRN4IoBrk8Ym2Bg=",
205+
"aud": "ura:12345678",
206+
"scope": "nvi",
207+
"version": "1.1",
208+
"iat": 1758616285,
209+
"exp": 1758616585
210+
}
211+
},
212+
"eval_subject": "-Jpsoeik2058ip20b9Wd-vlwpjkjxRN4IoBrk8Ym2Bg=",
213+
"final_pseudonym": "fDZYIlajLAV3y8fWl1ObFBDmybUEGrR37pDb-5p5pJJGKvvpDvvMdQmYHKqtiQ8tdF4VL3w8nkbssHtOmkjiOg=="
214+
}
215+
```
216+
217+
The `final_pseudonym` is the actual pseudonym that can be stored by the receiver. Note that this pseudonym is deterministic
218+
for the same input, organization and scope. However, it is not possible to reverse this into a BSN.

app/application.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from app.routers.rid import router as rid_router
1111
from app.routers.bpg import router as bpg_router
1212
from app.routers.oprf import router as oprf_router
13+
from app.routers.test_oprf import router as test_oprf_router
1314
from app.routers.key import router as key_router
1415
from app.config import get_config
1516

@@ -84,6 +85,7 @@ def setup_fastapi() -> FastAPI:
8485
rid_router,
8586
bpg_router,
8687
oprf_router,
88+
test_oprf_router,
8789
key_router,
8890
]
8991
for router in routers:

app/routers/oprf.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ def post_eval(
2121
if pub_key_jwk is None:
2222
return JSONResponse({"error": "No public key found for this organization"}, status_code=404)
2323

24-
jwe_str = oprf_service.eval_blind(req, pub_key_jwk)
24+
25+
try:
26+
jwe_str = oprf_service.eval_blind(req, pub_key_jwk)
27+
except ValueError as e:
28+
logger.warning(f"Unable to evaluate blind: {e}")
29+
return JSONResponse({"error": "Unable to evaluate blind"}, status_code=400)
30+
2531
return JSONResponse({"jwe": jwe_str})
2632

app/routers/test_oprf.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import json
2+
import logging
3+
4+
from fastapi import APIRouter, Depends
5+
from jwcrypto import jwe, jwk
6+
from pydantic import BaseModel
7+
from starlette.responses import JSONResponse
8+
9+
from app import container
10+
from app.services.oprf.oprf_service import OprfService
11+
12+
logger = logging.getLogger(__name__)
13+
router = APIRouter()
14+
15+
class InputRequest(BaseModel):
16+
personalId: str
17+
18+
class ReceiverRequest(BaseModel):
19+
blind_factor: str
20+
jwe: str
21+
priv_key_pem: str
22+
23+
@router.post("/test/oprf/gen_rsa_key", summary="Create a RSA (1024bit) key for test usage.", tags=["test-oprf"])
24+
def post_gen_rsa_key() -> JSONResponse:
25+
key = jwk.JWK.generate(kty='RSA', size=1024)
26+
priv_pem = key.export_to_pem(private_key=True, password=None).decode('ascii')
27+
pub_pem = key.export_to_pem(private_key=False, password=None).decode('ascii')
28+
29+
return JSONResponse({
30+
"private_key_pem": priv_pem,
31+
"public_key_pem": pub_pem,
32+
"public_key_kid": key.thumbprint().rstrip("="),
33+
})
34+
35+
36+
@router.post("/test/oprf/client", summary="Create a blinded input and factor for a given BSN (or any other input)", tags=["test-oprf"])
37+
def post_eval(
38+
req: InputRequest,
39+
oprf_service: OprfService = Depends(container.get_oprf_service),
40+
) -> JSONResponse:
41+
42+
res = oprf_service.blind_input(req.personalId)
43+
return JSONResponse({
44+
"blinded_input": res['blinded_input'],
45+
"blind_factor": res['blind_factor'],
46+
})
47+
48+
@router.post("/test/oprf/receiver", summary="Test receiver decryption of JWE with blind factor", tags=["test-oprf"])
49+
def post_receiver(
50+
req: ReceiverRequest,
51+
oprf_service: OprfService = Depends(container.get_oprf_service),
52+
) -> JSONResponse:
53+
54+
token = jwe.JWE()
55+
token.deserialize(req.jwe)
56+
headers = token.jose_header
57+
58+
priv_key_kid = "unknown"
59+
plain_data = "unknown"
60+
subject = "unknown"
61+
pseudonym = "unknown"
62+
try:
63+
priv_key = jwk.JWK.from_pem(req.priv_key_pem.encode('ascii'))
64+
priv_key_kid = priv_key.thumbprint().rstrip("=")
65+
token.decrypt(priv_key)
66+
plaintext = token.payload.decode('utf-8')
67+
plain_data = json.loads(plaintext)
68+
subject = plain_data.get("subject", "").split(":")[-1]
69+
pseudonym = oprf_service.finalize(req.blind_factor, subject)
70+
except Exception as e:
71+
plain_data = "Could not decrypt JWE: " + str(e)
72+
73+
74+
res = {
75+
'jwe_data': req.jwe,
76+
'priv_key_pem': req.priv_key_pem,
77+
'priv_key_kid': priv_key_kid,
78+
'blind_factor': req.blind_factor,
79+
'jwe': {
80+
'headers': headers,
81+
'decrypted': plain_data,
82+
},
83+
'eval_subject': subject,
84+
'final_pseudonym': pseudonym,
85+
}
86+
87+
return JSONResponse(res)
88+
89+

app/services/oprf/jwe_token.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
from jwcrypto import jwe, jwk
23
import json
34
import time

app/services/oprf/oprf_service.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ def eval_blind(self, req: BlindRequest, pub_key: jwk.JWK) -> str:
3737
"""
3838
Evaluate a blind and returns a JWE encrypted on the pubkey
3939
"""
40-
bi = base64.urlsafe_b64decode(req.encryptedPersonalId)
41-
eval = pyoprf.evaluate(self.__server_key, bi)
40+
try:
41+
bi = base64.urlsafe_b64decode(req.encryptedPersonalId)
42+
eval = pyoprf.evaluate(self.__server_key, bi)
43+
except Exception as e:
44+
raise ValueError(f"unable to evaluate blind: {e}")
4245

4346
subject = "pseudonym:eval:" + base64.urlsafe_b64encode(eval).decode('utf-8')
4447
jwe = BlindJwe.build(
@@ -49,3 +52,25 @@ def eval_blind(self, req: BlindRequest, pub_key: jwk.JWK) -> str:
4952
)
5053

5154
return jwe
55+
56+
@staticmethod
57+
def blind_input(input: str) -> dict[str, str]:
58+
"""
59+
Blind an input and returns the blind factor and the blinded input
60+
"""
61+
blind_factor, blinded_input = pyoprf.blind(input.encode('utf-8'))
62+
return {
63+
'blind_factor': base64.urlsafe_b64encode(blind_factor).decode('ascii'),
64+
'blinded_input': base64.urlsafe_b64encode(blinded_input).decode('ascii'),
65+
}
66+
67+
68+
@staticmethod
69+
def finalize(blind_factor: str, eval: str) -> str:
70+
"""
71+
Finalize the OPRF by unblinding the evaluated input with the blind factor
72+
"""
73+
bf = base64.urlsafe_b64decode(blind_factor)
74+
ev = base64.urlsafe_b64decode(eval)
75+
final = pyoprf.finalize(bf, ev)
76+
return base64.urlsafe_b64encode(final).decode('ascii')

docker-compose.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ services:
3434
ports:
3535
- 6379:6379
3636

37+
postgres:
38+
image: postgres:15
39+
environment:
40+
POSTGRES_USER: ${POSTGRES_USER:-postgres}
41+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
42+
POSTGRES_DB: ${POSTGRES_DB:-postgres}
43+
ports:
44+
- 5432:5432
45+
volumes:
46+
- pgdata:/var/lib/postgresql/data
47+
3748
volumes:
3849
secrets:
3950
name: secrets
51+
pgdata:
52+
name: pgdata

0 commit comments

Comments
 (0)