This app is the 'Pseudoniemendienst' and is part of the 'Generieke Functies, lokalisatie en addressering' project of the Ministry of Health, Welfare and Sport of the Dutch government.
The Pseudoniemendienst is a Proof of Concept application that explores the functionality and requirements of a pseudonymization service for pseudonymizing the Dutch citizen number (BSN) in various healthcare applications.
The Pseudoniemendienst is used in the Nationale Verwijsindex, by the Vertrouwde Authenticatie Dienst (VAD) and in the MedMij afsprakenstelsel.
This project and all associated code serve solely as documentation and demonstration purposes to illustrate potential system communication patterns and architectures.
This codebase:
- Is NOT intended for production use
- Does NOT represent a final specification
- Should NOT be considered feature-complete or secure
- May contain errors, omissions, or oversimplified implementations
- Has NOT been tested or hardened for real-world scenarios
The code examples are only meant to help understand concepts and demonstrate possibilities.
By using or referencing this code, you acknowledge that you do so at your own risk and that the authors assume no liability for any consequences of its use.
The PRS does not authenticate callers itself. Caller identity is verified by a separate upstream system (the OIN-verifier) that injects trusted headers, which the PRS trusts as-is. This means the PRS must never be reachable without that proxy in front of it. See docs/trust-model.md for the full trust model, the headers involved, and the deployment invariants that must hold.
This project can be setup and tested either as a python application directly on an operating system or in a Docker environment.
Quickstart
The easiest way is to start the docker-compose project by running:
docker compose upThis will start the project on 'http://localhost:6502'
The docker compose file contains the postgres database and the python app itself. Start both by running:
docker compose upThis will start the project on 'http://localhost:6502'. On first start the entrypoint creates app.conf from
app.conf.example with a fresh master key, generates the OPRF server key, runs the database migrations and seeds a
test organization (see docker/init.sh).
The PRS does not terminate mTLS or validate tokens itself. In a deployment the OIN-verifier proxy does that and
passes the verified caller to the PRS in x-gf-* headers (see docs/trust-model.md). When you
run the PRS on its own there is no proxy, so you send those headers yourself:
| Header | Value |
|---|---|
x-gf-sub |
OIN of the calling organization, e.g. 00000003123456780000 |
x-gf-act-sub |
OIN of the acting client, e.g. 00000003123456780000 |
x-gf-act-cn |
Common name of the client, e.g. prs.local |
x-gf-audience |
Must match authorization_headers.expected_audiences in app.conf |
x-gf-scope |
Space separated scopes, e.g. prs:administration prs:oprf-pseudonym |
The Swagger UI on http://localhost:6502/docs offers input fields for these headers when document_gf_headers = True
is set in the [uvicorn] section of app.conf (the default in app.conf.example).
Sometimes it's required, or easier to run the application natively on an operating system. To run and test the application on an operating system Poetry is used. Before you're able to install this project, the following requirements needs to be available:
Please see the official docs to follow the Poetry installation process.
This is required to build liboprf.
You can check if pkgconf is installed by running:
which pkgconfInstallation instructions vary depending on the operating system and available package managers.
This is required by liboprf.
You can check if libsodium is installed by running:
pkgconf --modversion libsodiumSee the official installation instructions to install libsodium.
Liboprf is used by the OPRF functionality of the Pseudoniemendienst.
See the installation instructions how to install this library.
After installing update the shared library cache by running:
sudo ldconfigThe container entrypoint (docker/init.sh) prepares everything automatically. When running natively you do the same
steps by hand, with a database available (for example docker compose up -d postgres):
-
Create
app.conffromapp.conf.exampleand setpseudonym.master_keyto a base64 encoded key of at least 32 bytes, for example the output ofopenssl rand -base64 32. -
Generate the OPRF server key:
poetry run python app/generate_oprf_key.py > secrets/oprf-server.key -
Run the database migrations:
DSN=postgresql://postgres:postgres@localhost:5432/postgres tools/migrate_db.sh
-
Seed a test organization (OIN
00000003123456780000, allowed to request and receive OPRF pseudonyms):poetry run python -m tools.seed
-
Start the application:
poetry run python -m app.main
The tests have a dependency on a postgres database. You can easily setup a database with docker:
docker compose up -d postgresNow you're able to run the pytest in poetry:
poetry run pytestThere are two ways to build a docker container from this application. The first is the default mode created with:
docker build \
--build-arg="NEW_UID=1000" \
--build-arg="NEW_GID=1000" \
-f docker/Dockerfile \
-t gfmodules-pseudoniemendienst \
.This will build a docker container that will run its migrations to the database specified in app.conf.
The second mode is a "standalone" mode, where it will not run migrations, and where you must explicitly specify an app.conf mount.
docker build \
--build-arg="standalone=true" \
-f docker/Dockerfile \
-t gfmodules-pseudoniemendienst \
.Both containers only differ in their init script and the default version usually will mount its own local src directory into the container's /src dir.
docker run -ti --rm -p 6502:6502 \
--mount type=bind,source=./app.conf.example,target=/src/app.conf \
--mount type=bind,source=./secrets,target=/src/secrets \
gfmodules-pseudoniemendienstThis system uses OPRF for pseudonym generation. To test this, there are some available endpoints:
- '/test/oprf/client' - Emulates a client that generates OPRF information for a given input
- '/test/oprf/receiver' - Emulates the receiver of the pseudonym and returns diagnostic information
To use this system:
-
Send the
x-gf-*headers with every request, as described in Authentication in development. The examples below use the seeded test organization, sox-gf-subandx-gf-act-subare00000003123456780000. The administration call needs theprs:administrationscope, the evaluation call needsprs:oprf-pseudonym. -
Make sure the organization exists. The container entrypoint and the native setup both seed the test organization with OIN
00000003123456780000viatools/seed.py. An organization must be allowed to request and to receive OPRF pseudonyms; the seeded one is both. -
Register the public key of the receiving organization. The PRS encrypts its response to this key. Registration goes through
POST /administration/keyswith a self-signed JWS: the JWS header carries the public key as ajwk(including akid), and the payload carries the organization'soinand aniatthat is at most one hour old. The signature proves possession of the matching private key. This snippet generates a key pair and the JWS:import time from jwcrypto.jwk import JWK from jwcrypto.jwt import JWT key = JWK.generate(kty="RSA", size=2048) key["kid"] = key.thumbprint() token = JWT( header={"alg": "RS256", "jwk": key.export_public(as_dict=True)}, claims={"iat": int(time.time()), "oin": "00000003123456780000"}, ) token.make_signed_token(key) print(token.serialize()) print(key.export_to_pem(private_key=True, password=None).decode())
Keep the printed private key; the receiver needs it in step 6. Then register the JWS for one or more scopes (
domains); a*entry acts as a wildcard for every scope:POST /administration/keys { "domains": ["nvi"], "jws": "eyJhbGciOiJSUzI1NiIsImp3ayI6ey...." } 201 Created { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "domains": ["nvi"], "jwk": { "kty": "RSA", "kid": "...", "n": "...", "e": "AQAB" } } -
Emulate a client wanting to send a pseudonym over to a receiver by calling
/test/oprf/clientwith a JSON body like:POST /test/oprf/client { "personalId": { "landCode": "NL", "type": "bsn", "value": "950000012" } } 200 OK { "blinded_input": "EJU9qVhKNmw_UhCXDN_aVM4GL1DCmpDs8QD5WOdUBCU=", "blind_factor": "eNf80WNHbImaUNU-kokBr7ocELBjMtHcy0re_RKBPQ8=" }This returns the
blinded_inputthat must be sent to the receiver, and theblind_factorthat must be sent to the receiver after the server has evaluated the blinded input.For the integration flow used by
/oprf/eval, derive the OPRF input from personal identifier + recipient context:info = f"{recipient_organization}|{recipient_scope}|v1".encode("utf-8") hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info) personal_id = json.dumps(personal_identifier, separators=(",", ":")) derived_personal_id = hkdf.derive(personal_id.encode("utf-8")) blind_factor, blinded_input = pyoprf.blind(derived_personal_id)
JSON should be canonicalized with RFC8785 for interoperable cryptographic input. Current implementation uses compact JSON (
separators=(",", ":")), which is deterministic but not full RFC8785 canonicalization. -
Now we can call the "real" OPRF function
/oprf/evalwith the blinded input, the recipient organization and scope:POST /oprf/eval { "encryptedPersonalId": "EJU9qVhKNmw_UhCXDN_aVM4GL1DCmpDs8QD5WOdUBCU=", "recipientOrganization": "oin:00000003123456780000", "recipientScope": "nvi" } 200 OK { "jwe": "eyJraWQiOi....bJUqbbSUIjqiw" }At this point we will get back a JWE that contains the evaluated blinded input and is encrypted with the public key of the organization. At this point, the client is not able to decrypt this information. It can only forward this to the receiver.
-
Now emulate the receiving party by calling
/test/oprf/receiverwith a JSON body like:POST /test/oprf/receiver { "blind_factor": "eNf80WNHbImaUNU-kokBr7ocELBjMtHcy0re_RKBPQ8=", "jwe": "eyJraWQiOiA...SzZbJUqbbSUIjqiw", "priv_key_pem": "-----BEGIN PRIVATE KEY----- MIIEvQIB...oCfe0= -----END PRIVATE KEY-----" }The blind factor is the one returned by the client, the JWE is the one returned by the PRS evaluation, and the private key is the one generated in step 3. It must be in PKCS#8 format (starting with
-----BEGIN PRIVATE KEY-----) and passed as a single line.At this point, it will return any diagnostic information about the OPRF process:
{ "jwe_data": "eyJraWQiOiAi...zZbJUqbbSUIjqiw", "priv_key_pem": "-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...oCfe0= -----END PRIVATE KEY-----", "priv_key_kid": "rNv1O_mXgxF6QEMfaQGvjev7RbT1FG3sJXxxsu_KHbM", "blind_factor": "eNf80WNHbImaUNU-kokBr7ocELBjMtHcy0re_RKBPQ8=", "jwe": { "headers": { "kid": "rNv1O_mXgxF6QEMfaQGvjev7RbT1FG3sJXxxsu_KHbM", "alg": "RSA-OAEP", "enc": "A256GCM", "cty": "application/json" }, "decrypted": { "subject": "pseudonym:eval:-Jpsoeik2058ip20b9Wd-vlwpjkjxRN4IoBrk8Ym2Bg=", "aud": "oin:00000003123456780000", "scope": "nvi", "version": "1.1", "iat": 1758616285, "exp": 1758616585, "extra_versions": {} } }, "eval_subject": "-Jpsoeik2058ip20b9Wd-vlwpjkjxRN4IoBrk8Ym2Bg=", "final_pseudonym": "fDZYIlajLAV3y8fWl1ObFBDmybUEGrR37pDb-5p5pJJGKvvpDvvMdQmYHKqtiQ8tdF4VL3w8nkbssHtOmkjiOg==" }The
final_pseudonymis the actual pseudonym that can be stored by the receiver. Note that this pseudonym is deterministic for the same input, organization and scope. However, it is not possible to reverse this into a BSN.The
subjectalways carries the evaluation for the latest key version. Theextra_versionsclaim is empty when only one key version is active; during key rotation it holds the older versions as{"<version>": "<base64 eval>"}, so the receiver can also finalize against an older key version.
The max-key-usage is a property that defines what kind of pseudonyms an organization can create and reverse.
There are 3 levels of max_key_usage:
- BSN - can create reversible and irreversible pseudonyms, and can reverse reversible pseudonyms back to BSN
- RP (Reversible Pseudonym) - can create reversible and irreversible pseudonyms, but cannot reverse any pseudonyms
- IRP (Irreversible Pseudonym) - can only create irreversible pseudonyms, and cannot reverse any pseudonyms
The "RP" level is mainly intended for organizations that need to create reversible pseudonyms for other organizations, but is not allowed to reverse them back to BSN itself.
The "IRP" level is intended for organizations that only need to create irreversible pseudonyms, and do not have access to BSN information at all.
An organization carries two administrator-managed lists of personal ID types (oprf, reversible_pseudonym,
irreversible_pseudonym; there is no self-service for them), following the "dubbele bevoegdheidscontrole" of the
technical design:
- the types it may request: checked against the verified caller (
x-gf-sub). Handing a personal ID to the PRS onPOST /exchange/reversible-pseudonymrequiresreversible_pseudonymhere. - the types it may receive: checked against the recipient organization of an exchange. Receiving a reversible
pseudonym requires
reversible_pseudonymhere.
As stated in the Disclaimer this project and all associated code serve solely as documentation and demonstration purposes to illustrate potential system communication patterns and architectures.
For that reason we will only accept contributions that fit this goal. We do appreciate any effort from the community, but because our time is limited it is possible that your PR or issue is closed without a full justification.
If you plan to make non-trivial changes, we recommend to open an issue beforehand where we can discuss your planned changes. This increases the chance that we might be able to use your contribution (or it avoids doing work if there are reasons why we wouldn't be able to use it).
Note that all commits should be signed using a gpg key.