👤 PRS — Pseudoniemendienst

OPRF-gebaseerde pseudoniemendienst. Vereist: mTLS én een OAuth-token voor https://pseudoniemendienst.proeftuin.gf.irealisatie.nl.

Overzicht 📜 mTLS 🔑 OAuth 👤 PRS 📑 NVI

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

PartijWeet welWeet niet
ClientHet BSN, de blind_factorDe serversleutel, het pseudoniem
PRS ServerDe serversleutel, het verblinde getalHet BSN, het pseudoniem
ReceiverHet 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 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.

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

OPRF swimlane: Client → PRS Server → Receiver
  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 Receiver.

    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 Receiver. De client kan de JWE niet ontsleutelen.

  3. 3

    Doorsturen naar Receiver — Client → Receiver

    De client stuurt blind_factor en 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. 4

    Ontsleutelen + unblinden — Receiver

    De Receiver 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.

NVI — vereiste waarden voor pseudoniem

Gebruik onderstaande waarden om een pseudoniem te genereren dat geschikt is voor gebruik bij de NVI:

RECV_ORGura:90000901
RECV_SCOPEnationale-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()
Beschikbare OPRF-bibliotheken

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

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.

Zowel JWE als blind_factor zijn verplicht

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:

  1. 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.
  2. 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.
Vereiste: organisatie moet eerst geregistreerd zijn

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

← OAuth Volgende: NVI →