π€ PRS β Pseudoniemendienst
De PRS biedt vier gebruiksmogelijkheden voor pseudonimisering. Volg eerst de aansluitinstructies en haal daarna via OAuth een Bearer-token op.
Wat de PRS doet
Met de Pseudoniemendienst (PRS) maak je organisatie-specifieke pseudoniemen op basis van een persoonsnummer, zoals het BSN. Zo kunnen organisaties over een persoon communiceren zonder het persoonsnummer onderling uit te wisselen.
Welke partij het persoonsnummer verwerkt en wie het pseudoniem ontvangt, hangt af van de gekozen gebruiksmogelijkheid.
Vier gebruiksmogelijkheden
Je kunt de PRS op vier manieren gebruiken. Hieronder staat per gebruiksmogelijkheid hoe het pseudoniem tot stand komt.
Omkeerbaar pseudoniem
De PRS leidt een pseudoniem af dat zij, uitsluitend voor geautoriseerde organisaties, later kan terugrekenen naar het persoonsnummer.
Niet-omkeerbaar pseudoniem
De PRS berekent een pseudoniem waarvoor geen terugrekenfunctie bestaat en bewaart geen persoonsnummer-pseudoniemkoppeling.
DigiD SAML-exchange
De PRS valideert en ontsleutelt een DigiD SAML-assertion intern en retourneert een pseudoniem, zodat de aanvragende dienst het persoonsnummer niet ziet.
Geblindeerde OPRF-uitwisseling
Een aanvrager blindeert het persoonsnummer lokaal. De PRS evalueert alleen die geblindeerde invoer; de ontvangende organisatie leidt als enige het pseudoniem af.
Ontwerpprincipes
Context en niet-koppelbaarheid
Voor omkeerbare en niet-omkeerbare pseudoniemen bepaalt de combinatie van doelorganisatie, domein en sleutelversie de uitkomst. Binnen die context is een pseudoniem stabiel; tussen organisaties of domeinen niet onderling koppelbaar.
Beschermde uitvoer
Uitvoer die voor een ontvangende organisatie bestemd is, wordt in het ontwerp voor die organisatie versleuteld. Die organisatie beheert haar eigen decryptie-private key.
Minimale gegevensverwerking
De PRS bewaart geen persoonsnummer-pseudoniemmapping en neemt geen persoonsnummers of pseudoniemen op in de audit-log. Bij directe pseudonimisering en DigiD SAML-exchange is het persoonsnummer wel tijdelijk aanwezig binnen de vertrouwde PRS-infrastructuur.
Beheerde toegang
Het ontwerp gebruikt mTLS, OAuth 2.0 en per operatie beheerde bevoegdheden. Langlevend PRS-sleutelmateriaal valt onder een HSM/KMS-regime.
Een domein is een pseudoniemnamespace voor een gebruikstoepassing, terwijl een OAuth-scope een autorisatiebegrip is. Gebruik voor de OPRF-uitwisseling de parameter recipientScope.
OAuth-token voor PRS
PRS heeft een eigen OAuth 2.0 Authorization Service. Gebruik dit endpoint om een token voor de PRS API op te halen. Stuur de vier verplichte formuliervelden grant_type, scope, target_audience en organization_id mee. De voorbeelden hieronder gebruiken mTLS en een voorbeeldscope.
Vervang <prs-oauth-url> door het serveradres van de OAuth-service, inclusief https:// en zonder afsluitende slash.
<prs-oauth-url>/oauth/token
Parameters en scopes
| Parameter | Waarde | Toelichting |
|---|---|---|
grant_type | client_credentials | Verplicht formulierveld. |
target_audience | <target-audience> | Verplicht formulierveld: identificatie van de doelservice, geen endpointpad. |
organization_id | <organisatie-id> | Verplicht formulierveld: ID van de aanvragende organisatie. |
scope | prs:read | Voorbeeldscope. |
De voorbeelden gebruiken prs:read.
Token ophalen
Dit integratievoorbeeld gebruikt grant_type=client_credentials en een PKIoverheid-certificaat.
Vul <prs-oauth-url> in met het OAuth-serveradres voordat je de cURL-, PHP- of Python-voorbeelden uitvoert. Vervang <target-audience> door de identificatie van de doelservice.
curl -s -X POST \
--cert client-cert.pem \
--key client-key.pem \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=prs:read" \
--data-urlencode "target_audience=<target-audience>" \
--data-urlencode "organization_id=<organisatie-id>" \
"<prs-oauth-url>/oauth/token" \
| python3 -m json.tool[$status, $body] = curlRequest($certFile, $keyFile, '<prs-oauth-url>/oauth/token', [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'client_credentials',
'scope' => 'prs:read',
'target_audience' => '<target-audience>',
'organization_id' => '<organisatie-id>',
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);import requests
session = requests.Session()
session.cert = ("client-cert.pem", "client-key.pem")
resp = session.post(
"<prs-oauth-url>/oauth/token",
data={
"grant_type": "client_credentials",
"scope": "prs:read",
"target_audience": "<target-audience>",
"organization_id": "<organisatie-id>",
},
)Gebruik de metadata en JWKS van de OAuth-service van PRS:
/oauth/.well-known/oauth-authorization-server/oauth/jwks.json
Geblindeerde OPRF-uitwisseling
Bij deze gebruiksmogelijkheid gebruikt de PRS een OPRF-protocol (Oblivious Pseudo-Random Function) om BSN-nummers om te zetten naar deterministische, onomkeerbare pseudoniemen. Bij de geblindeerde uitwisseling ziet de server nooit het plaintext BSN; de client ziet nooit de serversleutel. Gebruik voor de aanroepen een Bearer-token en mTLS volgens de aansluitinstructies.
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 | De serversleutel, het verblinde getal | Het BSN, het pseudoniem |
| Ontvanger | 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 ontvanger 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 ontvanger. Alleen de ontvanger 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 ontvanger, 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 ontvanger ziet nooit het BSN.
- Elke organisatie en scope krijgt een uniek, niet-koppelbaar pseudoniem.
Algemene OPRF-flow β drie partijen, vier stappen
Dit diagram toont de uitwisseling tussen de client, de PRS en de ontvangende dienst. De ontvangende dienst bepaalt hoe de client de uitvoer aanlevert.
- 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 ontvanger.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 ontvanger. De client kan de JWE niet ontsleutelen.
- 3
Doorsturen naar de ontvanger β Client β Ontvanger
Stuur de JWE en de
blind_factordoor naar de ontvangende dienst. Alleen de ontvanger kan de JWE ontsleutelen β hij beschikt over de bijbehorende private key. - 4
Ontsleutelen + unblinden β Ontvanger
De ontvanger 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.
Vul onderstaande waarden in voor de ontvangende dienst. recipientOrganization is een OIN in de vorm oin:<20-cijferig-oin>. De parameternaam recipientScope is onderdeel van dit contract; zie hierboven voor het onderscheid tussen een domein en een OAuth-scope.
| Variabele | Waarde |
|---|---|
RECV_ORG | oin:<20-cijferig-oin-ontvanger> |
RECV_SCOPE | <ontvanger-scope> |
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.
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)
Vervang in dit voorbeeld <prs-api-url> door het PRS-serveradres, inclusief https:// en zonder afsluitende slash.
PRS_BASE_URL = "<prs-api-url>/api"
session = requests.Session()
session.cert = ("client-cert.pem", "client-key.pem")
resp = session.post(
f"{PRS_BASE_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 ontvanger β client kan dit NIET lezenStap 3 β Doorsturen naar de ontvanger
Stuur de ontvangen jwe_token samen met de blind_factor naar de ontvangende dienst. Gebruik het endpoint en het formaat dat die dienst voorschrijft. De ontvanger ontsleutelt de JWE en gebruikt de blind factor voor het unblinden.
Voor registratie bij de NVI volg je de instructies voor lokalisatie registreren in de NVI-documentatie.
Ontvanger instellen
Richt de ontvanger in met de volgende twee stappen voordat je /oprf/eval gebruikt:
- Organisatie registreren bij de PRS β laat je organisatie registreren door de beheerder van de betreffende PRS-dienst voordat je verdergaat.
- Publieke sleutel registreren β nadat je organisatie is geregistreerd, voeg je jouw publieke sleutel eenmalig toe via
POST /administration/register/certificate. De PRS leest de sleutel automatisch uit je PKIoverheid-certificaat.
De aanroep naar /administration/register/certificate mislukt als jouw organisatie nog niet door een beheerder is toegevoegd. Zorg dat stap 1 is afgerond voordat je stap 2 uitvoert.
Vervang in dit voorbeeld <prs-api-url> door het PRS-serveradres, inclusief https:// en zonder afsluitende slash.
curl -s -X POST \
--cert client-cert.pem \
--key client-key.pem \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope": ["<scope>"]}' \
"<prs-api-url>/api/administration/register/certificate"<scope> is een vrij te kiezen waarde die door de beheerder van de ontvanger 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 ontvanger.
Stap 4 β Ontsleutelen + unblinden (ontvanger)
De ontvanger 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 ontvanger
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 ontvanger
// (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";