πŸ‘€ 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.

Domein is niet hetzelfde als OAuth-scope

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.

Token <prs-oauth-url>/oauth/token

Parameters en scopes

ParameterWaardeToelichting
grant_typeclient_credentialsVerplicht formulierveld.
target_audience<target-audience>Verplicht formulierveld: identificatie van de doelservice, geen endpointpad.
organization_id<organisatie-id>Verplicht formulierveld: ID van de aanvragende organisatie.
scopeprs:readVoorbeeldscope.

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>",
    },
)
Metadata en tokenverificatie

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

PartijWeet welWeet niet
ClientHet BSN, de blind_factorDe serversleutel, het pseudoniem
PRSDe serversleutel, het verblinde getalHet BSN, het pseudoniem
OntvangerHet pseudoniem, zijn eigen private keyHet 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.

Samenvatting privacygaranties
  • 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

OPRF swimlane: Client β†’ PRS β†’ Ontvanger

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. 1

    BSN afschermen β€” Client

    Maak een HKDF-afleiding van het BSN met de ontvanger als context, en blind het resultaat met pyoprf.blind(). Bewaar de blind_factor lokaal β€” die is later nodig voor de ontvanger.

    Let op: de gebruikte curve voor OPRF is Ristretto255 / Sha512.

  2. 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. 3

    Doorsturen naar de ontvanger β€” Client β†’ Ontvanger

    Stuur de JWE en de blind_factor door naar de ontvangende dienst. Alleen de ontvanger kan de JWE ontsleutelen β€” hij beschikt over de bijbehorende private key.

  4. 4

    Ontsleutelen + unblinden β€” Ontvanger

    De ontvanger ontsleutelt de JWE met zijn private key (jwcrypto) en combineert de geΓ«valueerde waarde met de blind_factor via pyoprf.unblind(). Het resultaat is het definitieve, deterministische pseudoniem.

Ontvangerwaarden voor 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.

VariabeleWaarde
RECV_ORGoin:<20-cijferig-oin-ontvanger>
RECV_SCOPE<ontvanger-scope>
Deterministisch & organisatie-specifiek

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()
Beschikbare OPRF-bibliotheken

Niet alle programmeertalen hebben een volwaardige OPRF-implementatie beschikbaar. Bekende bibliotheken:

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 lezen

Stap 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:

  1. Organisatie registreren bij de PRS β€” laat je organisatie registreren door de beheerder van de betreffende PRS-dienst voordat je verdergaat.
  2. 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.
Vereiste: organisatie moet eerst geregistreerd zijn

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";