Skip to content

Repository files navigation

GFModules Pseudoniemendienst

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.

Disclaimer

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.

Security & trust model

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.

Development setup

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 up

This will start the project on 'http://localhost:6502'

Docker development setup

The docker compose file contains the postgres database and the python app itself. Start both by running:

docker compose up

This 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).

Authentication in development

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

Poetry development setup

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:

Poetry Development dependencies

Poetry

Please see the official docs to follow the Poetry installation process.

pkgconf

This is required to build liboprf.

You can check if pkgconf is installed by running:

which pkgconf

Installation instructions vary depending on the operating system and available package managers.

Libsodium-dev

This is required by liboprf.

You can check if libsodium is installed by running:

pkgconf --modversion libsodium

See the official installation instructions to install libsodium.

liboprf

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 ldconfig

Running the application natively

The 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):

  1. Create app.conf from app.conf.example and set pseudonym.master_key to a base64 encoded key of at least 32 bytes, for example the output of openssl rand -base64 32.

  2. Generate the OPRF server key: poetry run python app/generate_oprf_key.py > secrets/oprf-server.key

  3. Run the database migrations:

    DSN=postgresql://postgres:postgres@localhost:5432/postgres tools/migrate_db.sh
  4. Seed a test organization (OIN 00000003123456780000, allowed to request and receive OPRF pseudonyms):

    poetry run python -m tools.seed
  5. Start the application: poetry run python -m app.main

Poetry pytest

The tests have a dependency on a postgres database. You can easily setup a database with docker:

docker compose up -d postgres

Now you're able to run the pytest in poetry:

poetry run pytest

Docker container builds

There 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-pseudoniemendienst

OPRF Testing

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

  1. Send the x-gf-* headers with every request, as described in Authentication in development. The examples below use the seeded test organization, so x-gf-sub and x-gf-act-sub are 00000003123456780000. The administration call needs the prs:administration scope, the evaluation call needs prs:oprf-pseudonym.

  2. Make sure the organization exists. The container entrypoint and the native setup both seed the test organization with OIN 00000003123456780000 via tools/seed.py. An organization must be allowed to request and to receive OPRF pseudonyms; the seeded one is both.

  3. Register the public key of the receiving organization. The PRS encrypts its response to this key. Registration goes through POST /administration/keys with a self-signed JWS: the JWS header carries the public key as a jwk (including a kid), and the payload carries the organization's oin and an iat that 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" }
    }
  4. Emulate a client wanting to send a pseudonym over to a receiver by calling /test/oprf/client with 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_input that must be sent to the receiver, and the blind_factor that 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.

  5. Now we can call the "real" OPRF function /oprf/eval with 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.

  6. Now emulate the receiving party by calling /test/oprf/receiver with 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_pseudonym is 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 subject always carries the evaluation for the latest key version. The extra_versions claim 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.

max-key-usage

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.

Personal ID type authorizations

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 on POST /exchange/reversible-pseudonym requires reversible_pseudonym here.
  • the types it may receive: checked against the recipient organization of an exchange. Receiving a reversible pseudonym requires reversible_pseudonym here.

Contribution

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.

About

GFModules pseudoniemen registratie service

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages