👤 PRS — Pseudoniemendienst
OPRF-gebaseerde pseudoniemendienst. Vereist: mTLS én een OAuth-token voor https://pseudoniemendienst.proeftuin.gf.irealisatie.nl.
PRS — Pseudoniemendienst
De Pseudoniemendienst gebruikt een OPRF-protocol (Oblivious Pseudo-Random Function) om BSN-nummers om te zetten naar deterministische, onomkeerbare pseudoniemen. De server ziet nooit het plaintext BSN; de client ziet nooit de serversleutel. Elke aanvraag vereist mTLS én een geldig OAuth-token.
Wat is OPRF en waarom drie partijen?
OPRF staat voor Oblivious Pseudo-Random Function. Het woord "oblivious" is hier essentieel: de server die de berekening uitvoert (de PRS) is zich niet bewust van de invoer (het BSN) en ook niet van de uitvoer (het pseudoniem). Geen van de drie partijen heeft op zichzelf genoeg informatie om de koppeling tussen BSN en pseudoniem te maken.
De drie partijen en wat ze weten
| Partij | Weet wel | Weet niet |
|---|---|---|
| Client | Het BSN, de blind_factor | De serversleutel, het pseudoniem |
| PRS Server | De serversleutel, het verblinde getal | Het BSN, het pseudoniem |
| Receiver | Het pseudoniem, zijn eigen private key | Het BSN, de serversleutel |
Hoe werkt blinding?
Blinding is vergelijkbaar met het versturen van een slot zonder sleutel. De client past een wiskundige vermomming toe op het BSN — de blind factor — voordat hij het naar de PRS stuurt. De PRS voert zijn berekening uit op de verblinde waarde en stuurt het resultaat terug. Doordat de PRS de blind factor nooit ziet, kan hij het resultaat niet koppelen aan het originele BSN. De client kan het resultaat vervolgens "ontmaskeren" (unblinden) — maar in dit protocol doet de Receiver dat, niet de client zelf.
Waarom kan de client het resultaat niet zien?
De PRS versleutelt het evaluatieresultaat als een JWE (JSON Web Encryption)
met de publieke sleutel van de Receiver. Alleen de Receiver bezit de bijbehorende private key
en kan de JWE openen. De client ontvangt de JWE en stuurt die — samen met de
blind_factor — door naar de Receiver, maar kan de inhoud zelf niet lezen.
Zo weet de client het BSN maar niet het pseudoniem.
Waarom is het pseudoniem deterministisch?
De OPRF-functie is deterministisch: dezelfde invoer geeft met dezelfde serversleutel altijd
hetzelfde resultaat. Dat maakt het pseudoniem stabiel over meerdere aanvragen. Bovendien
wordt het BSN eerst via HKDF afgeleid met de ontvanger en scope als context
(org|scope|v1). Daardoor levert hetzelfde BSN voor elke organisatie en scope
een ander verbind getal op, en dus een ander pseudoniem — twee organisaties kunnen
iemand niet via het pseudoniem koppelen.
- De PRS ziet nooit een plaintext BSN of het definitieve pseudoniem.
- De client ziet nooit de serversleutel of het definitieve pseudoniem.
- De Receiver ziet nooit het BSN.
- Elke organisatie en scope krijgt een uniek, niet-koppelbaar pseudoniem.
OPRF-flow — drie partijen, vier stappen
- 1
BSN afschermen — Client
Maak een HKDF-afleiding van het BSN met de ontvanger als context, en blind het resultaat met
pyoprf.blind(). Bewaar deblind_factorlokaal — die is later nodig voor de Receiver.Let op: de gebruikte curve voor OPRF is Ristretto255 / Sha512.
- 2
Evaluatie aanvragen — Client → PRS (
POST /oprf/eval)Stuur het verblinde getal naar de PRS. De server evalueert het met zijn sleutel en retourneert het resultaat versleuteld als JWE — versleuteld met de publieke sleutel van de Receiver. De client kan de JWE niet ontsleutelen.
- 3
Doorsturen naar Receiver — Client → Receiver
De client stuurt
blind_factoren de JWE samen door naar de Receiver. De Receiver is de enige partij die de JWE kan ontsleutelen — hij beschikt over de bijbehorende private key. - 4
Ontsleutelen + unblinden — Receiver
De Receiver ontsleutelt de JWE met zijn private key (
jwcrypto) en combineert de geëvalueerde waarde met deblind_factorviapyoprf.unblind(). Het resultaat is het definitieve, deterministische pseudoniem.
Gebruik onderstaande waarden om een pseudoniem te genereren dat geschikt is voor gebruik bij de NVI:
| RECV_ORG | ura:90000901 |
| RECV_SCOPE | nationale-verwijsindex |
Stap 1 — BSN afschermen
1a. BSN opmaken als JSON
De PRS verwacht geen kale BSN-string als invoer, maar een gestructureerd JSON-object. Gebruik hiervoor een canonieke JSON-representatie conform RFC 8785 (JSON Canonicalization Scheme). Als referentie-implementatie kun je bijvoorbeeld trailofbits/rfc8785.py gebruiken.
import json import rfc8785 BSN = "999993653" pid = rfc8785.dumps({ "landCode": "NL", "type": "BSN", "value": BSN }).decode() # Example using json.dumps(...) example_json_dumps = json.dumps({"type": "BSN", "value": BSN, "landCode": "NL"}, separators=(",", ":"), sort_keys=True)
Verwachte canonieke JSON-uitvoer voor bovenstaande invoer:
{"landCode":"NL","type":"BSN","value":"999993653"}
1b. HKDF-afleiding — scope-specifieke input genereren
Het JSON-object wordt niet rechtstreeks aan de OPRF-functie aangeboden. Eerst wordt via
HKDF (HMAC-based Key Derivation Function) een 32-byte waarde afgeleid.
De info-parameter — opgebouwd als org|scope|v1 — bindt de
afleiding aan een specifieke ontvanger en scope. Hetzelfde BSN levert daardoor voor elke
organisatie en scope een andere afgeleide waarde op, zodat twee ontvangers het
resultaat nooit kunnen koppelen.
info = f"{RECV_ORG}|{RECV_SCOPE}|v1".encode() hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info) derived = hkdf.derive(pid.encode())
1c. Blinding — invoer verbergen voor de PRS
De afgeleide waarde wordt geblind met pyoprf.blind(). Dit past een willekeurige
wiskundige vermomming toe zodat de PRS de werkelijke invoer nooit ziet.
De functie retourneert twee waarden: de blind_factor (geheim, lokaal bewaren —
nodig in stap 3) en de blinded_input die naar de PRS wordt gestuurd.
De blinded_input wordt als URL-safe Base64 geëncodeerd voor transport.
blind_factor, blinded_input = pyoprf.blind(derived) bi_b64 = base64.urlsafe_b64encode(blinded_input).decode()
Niet alle programmeertalen hebben een volwaardige OPRF-implementatie beschikbaar. Bekende bibliotheken:
- Python —
pyoprf - PHP — github.com/jaytaph/oprf-php
Stap 2 — Evaluatie aanvragen bij de PRS (POST /oprf/eval)
session = requests.Session() session.cert = ("client-uzi-chain.crt", "client-uzi.key") resp = session.post( f"{PRS_URL}/oprf/eval", headers={"Authorization": "Bearer <token>"}, json={ "encryptedPersonalId": bi_b64, "recipientOrganization": RECV_ORG, "recipientScope": RECV_SCOPE, }, ) resp.raise_for_status() jwe_token = resp.json()["jwe"] # versleuteld voor de Receiver — client kan dit NIET lezen
Stap 3 — Doorsturen naar de Receiver
De client stuurt het resultaat door naar de Receiver — dit kan de NVI zijn, maar ook een andere partij
die als Receiver is geregistreerd. Welke Receiver je aanspreekt hangt af van de recipientOrganization
en recipientScope die je in stap 2 hebt meegegeven.
De Receiver heeft beide waarden nodig om het definitieve pseudoniem te berekenen:
de jwe bevat het geëvalueerde resultaat versleuteld met de publieke sleutel van de Receiver,
en de blind_factor is nodig om de blindingsstap ongedaan te maken (unblinden).
Zonder één van beide kan het pseudoniem niet worden afgeleid.
RECEIVER_URL = "<receiver-url>" # bijv. NVI_URL voor de NVI requests.post( f"{RECEIVER_URL}/<oprf-receive-endpoint>", json={ "blind_factor": base64.urlsafe_b64encode(blind_factor).decode(), "jwe": jwe_token, }, )
Receiver instellen
Om als Receiver te kunnen ontvangen zijn twee stappen vereist voordat je /oprf/eval kunt gebruiken:
- Organisatie registreren bij de PRS — dit kan uitsluitend worden gedaan door een gfmodules-beheerder. Neem contact op via helpdesk@irealisatie.nl om je organisatie te laten registreren voordat je verdergaat.
- Publieke sleutel registreren — nadat je organisatie is geregistreerd, voeg je jouw publieke sleutel
eenmalig toe via
POST /register/certificate. De PRS leest de sleutel automatisch uit je mTLS-certificaat.
De aanroep naar /register/certificate mislukt als jouw organisatie nog niet door een beheerder is toegevoegd. Zorg dat stap 1 is afgerond voordat je stap 2 uitvoert.
curl -s -X POST \
--cert client-uzi-chain.crt \
--key client-uzi.key \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope": ["<scope>"]}' \
https://pseudoniemendienst.proeftuin.gf.irealisatie.nl/register/certificate<scope> is een vrij te kiezen waarde die door de beheerder van de Receiver wordt bepaald.
Deze waarde moet exact overeenkomen met de recipientScope die later bij POST /oprf/eval wordt meegegeven —
anders kan de PRS het resultaat niet versleutelen voor de juiste Receiver.
Stap 4 — Ontsleutelen + unblinden (Receiver)
De Receiver ontvangt de jwe en blind_factor van de Client.
Hij ontsleutelt de JWE met zijn private key om het geëvalueerde element te verkrijgen,
en past vervolgens de inverse blindingsstap toe om het definitieve pseudoniem te berekenen.
Vereiste packages: pyoprf, jwcrypto
import base64, json import pyoprf from jwcrypto import jwe, jwk RECEIVER_PRIVATE_KEY_PEM = "-----BEGIN PRIVATE KEY-----\n..." # Ontvangen van de Client blind_factor = base64.urlsafe_b64decode("<blind_factor_b64>") jwe_token = "<jwe-string>" # Stap 4a — JWE ontsleutelen met de private key van de Receiver priv_key = jwk.JWK.from_pem(RECEIVER_PRIVATE_KEY_PEM.encode()) token = jwe.JWE() token.deserialize(jwe_token) token.decrypt(priv_key) payload = json.loads(token.payload) # Stap 4b — Unblinden → definitief pseudoniem eval_b64 = payload["subject"].split(":")[-1] eval_bytes = base64.urlsafe_b64decode(eval_b64) pseudonym = base64.urlsafe_b64encode( pyoprf.unblind(blind_factor, eval_bytes) ).decode() print("Pseudoniem:", pseudonym)
Vereiste packages: noxlogic/oprf (≥ v0.9.1), een JWE-bibliotheek (bijv. web-token/jwt-framework)
use Noxlogic\Oprf\OprfClient; // Ontvangen van de Client $blind = '<raw-blind-scalar>'; // binaire string, 32 bytes $jweToken = '<jwe-string>'; // Stap 4a — JWE ontsleutelen met de private key van de Receiver // (gebruik je eigen JWE-bibliotheek; het payload is een JSON-object) $payload = jweDecrypt($jweToken, $receiverPrivateKey); $parts = explode(':', $payload['subject']); $evaluatedElement = base64_decode(end($parts)); // Stap 4b — Unblinden → definitief pseudoniem $client = new OprfClient(); $unblinded = $client->unblind($blind, $evaluatedElement); $pseudonym = base64_encode($unblinded); echo "Pseudoniem: $pseudonym\n";
Hetzelfde BSN levert voor elke combinatie van recipientOrganization en recipientScope
een ander pseudoniem op. Twee organisaties kunnen een persoon dus niet op basis van pseudoniem
koppelen.