Skip to content

Generic did:x509 credential validation (PSA 10.4, 10.6.2) #4079

@stevenvegt

Description

@stevenvegt

Related: #4078, #3965

Problem Statement

The Nuts node currently has did:x509-specific validation logic (certificate chain validation, CRL checks, issuer-DID-to-credentialSubject attribute matching) inside the X509CredentialValidator — a type-specific validator that only runs for credentials typed as X509Credential. New credential types issued by did:x509 identities (PatientEnrollmentCredential, HealthcareProfessionalDelegationCredential, HealthcareProviderCredential) do not receive these checks, falling through to defaultCredentialValidator. This means these credentials cannot be properly validated.

The LSPxNuts PSA defines generic validation rules (PSA 10.6.2) that apply to ALL credentials regardless of type, including DID/key resolution (rules 6-8), issuer attribute matching (rule 9), and trust anchor validation (rule 10). These rules should be enforced by the DID/key resolution layer (PSA 10.4.3), not by credential-type-specific validators.

Solution

Refactor the validation architecture so that did:x509-specific checks are performed at the DID/key resolution layer, making them apply to any credential with a did:x509 issuer regardless of credential type.

Part A: Move CRL/cert chain/key resolution to the did:x509 resolver layer (PSA 10.4.2, 10.4.3)

Certificate chain validation, trust anchor checks, and CRL checks currently in X509CredentialValidator are moved to the did:x509 DID resolution and key resolution steps. Key resolution uses the credential's issuanceDate as reference time (PSA 10.4.3).

Part B: Issuer-DID-to-credentialSubject attribute matching (PSA 10.6.2 rule 9)

Identifier values in credentialSubject that originate from the issuer DID must match. This is implemented as:

  • A recursive walk of the credentialSubject finding all identifier objects (with system/value pairs) and matching them against the corresponding issuer DID attributes
  • A known special case for roleCode which uses a direct attribute rather than the identifier pattern

Pastype validation (e.g., pastype S for HealthcareProviderCredential, pastype Z or N for PatientEnrollmentCredential) is handled via PD constraints on the $.issuer field using regex patterns (e.g., "pattern": "-[ZN]-"). This requires no code changes — it is purely a PD configuration concern.

User Stories

  1. As a Nuts node operator, I want PatientEnrollmentCredentials issued by did:x509 to be validated with the same rigor as X509Credentials (cert chain, CRL, attribute matching), so that I can trust their authenticity.
  2. As a Nuts node operator, I want HealthcareProfessionalDelegationCredentials issued by did:x509 to have their UZI-nummer and roleCode validated against the issuer DID, so that credential forgery is detected.
  3. As a Nuts node operator, I want HealthcareProviderCredentials issued by did:x509 to have their URA validated against the issuer DID, so that organization identity is verified.
  4. As a Nuts node operator, I want CRL checks to happen at the key resolution layer for all did:x509-issued credentials, so that revoked certificates are caught regardless of credential type.
  5. As a Nuts node operator, I want trust anchor validation to happen at the DID resolution layer, so that untrusted certificate authorities are rejected for any credential type.
  6. As a Nuts node operator, I want key validity to be checked at the credential's issuance time (PSA 10.4.3), so that credentials signed by keys that were valid at issuance but later revoked remain accepted.
  7. As a Nuts node operator, I want existing X509Credential validation behavior to be preserved, so that current use cases are not broken by the refactoring.
  8. As a developer, I want the validation architecture to follow the PSA spec's layered model (DID resolution → key resolution → signature verification → PD matching), so that adding new credential types doesn't require type-specific validators.

Implementation Decisions

Part A: Move did:x509 validation to resolver layer

The following logic is moved from X509CredentialValidator.Validate() to the did:x509 DID/key resolution layer:

  • Certificate chain parsing and validation → did:x509 DID resolution
  • Trust anchor verification (ca-fingerprint) → did:x509 DID resolution
  • CRL check → key resolution (checked at reference time per PSA 10.4.3)
  • Credential time vs certificate time validation (issuanceDate after notBefore, expirationDate before notAfter) → key resolution

After this change, any credential with a did:x509 issuer automatically gets these checks through the signature verification step, which resolves the DID and key.

Part B: Issuer attribute matching

The matching logic recursively walks the credentialSubject JSON to find all objects with system and value fields (identifier pattern). For each, it looks up the corresponding attribute in the issuer DID based on the system URI:

  • http://fhir.nl/fhir/NamingSystem/ura → URA from issuer DID
  • http://fhir.nl/fhir/NamingSystem/uzi-nr-pers → UZI-nummer from issuer DID

Additionally, roleCode is handled as a known special case (direct attribute, not an identifier object).

This logic is integrated into the Verify function after signature verification, which has already resolved the issuer DID.

Known limitation: the recursive walk and roleCode special case contain some knowledge of credential structure. A future improvement could make this fully configurable via PD constraints with named captures/variables, removing the need for any hardcoded knowledge.

Pastype validation

Certificate type (pastype) requirements per credential type (e.g., pastype S for server certificates, Z for zorgverlenerspas, N for medewerkerspas) are enforced via PD constraints on the $.issuer field. The pastype is encoded in the san:otherName of the did:x509 DID string, so a regex pattern in the PD can enforce the correct type. Example for PatientEnrollmentCredential (requires pastype Z or N):

{
  "path": ["$.issuer"],
  "filter": {
    "type": "string",
    "pattern": "-[ZN]-"
  }
}

This requires no code changes — only PD configuration.

Impact on existing validators

After the refactoring, the X509CredentialValidator becomes largely empty — its logic has moved to lower layers. It can either be removed or kept as a thin wrapper. Existing behavior is preserved.

Modules to build/modify

  1. did:x509 resolver (modify): incorporate certificate chain validation, trust anchor checks
  2. Key resolution (modify): add CRL check at reference time, credential-vs-certificate time validation
  3. Issuer attribute matching (new or modify Verify): recursive identifier walk + roleCode special case, applied to any did:x509-issued credential
  4. X509CredentialValidator (modify/simplify): remove logic that has moved to lower layers

Testing Decisions

A good test verifies that credentials with did:x509 issuers are validated correctly regardless of credential type.

Modules to test:

  • did:x509 resolver: unit tests for cert chain validation, trust anchor rejection
  • Key resolution: unit tests for CRL check at reference time, time validity, key valid at issuance but later revoked
  • Issuer attribute matching: unit tests for URA, UZI-nummer, roleCode matching, mismatches producing errors, deeply nested identifiers
  • Backward compatibility: existing X509Credential tests must pass unchanged
  • New credential types: tests for PatientEnrollmentCredential, HealthcareProfessionalDelegationCredential with did:x509 issuers getting full validation
  • Prior art: existing tests in vcr/credential/validator_test.go, vcr/verifier/verifier_test.go

Out of Scope

  • credentialSchema (JSON Schema) support for structural validation (PSA 10.6.1)
  • PD-based configurable attribute matching (named captures/variables in PD constraints)
  • ServiceProviderCredential and ServiceProviderDelegationCredential (did:web issued, not relevant for did:x509 validation)
  • Changes to OAuth flow logic

Further Notes

  • This refactoring is a hard dependency for server-side JWT bearer support — without it, did:x509-issued credentials in VPs cannot be properly validated.
  • Per PSA 10.6.5, CRL policy for PatientEnrollmentCredentials follows generic key resolution (PSA 10.4.3): if the key was valid and not revoked at issuance time, the signature is valid even if the key was later revoked.
  • Future direction: PD constraints with named regex captures could replace the hardcoded attribute matching. A PD field filter could capture named groups and require them to match values from the issuer DID. This would make the matching fully configurable and remove credential-structure knowledge from the codebase.

Dependency graph

  #4082 ──┬──> #4083 ──┐
          └──> #4084 ──┼──> #4086
  #4085 ───────────────┘

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions