Repository navigation
Generic did:x509 credential validation (PSA 10.4, 10.6.2) #4079
Description
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
identifierobjects (withsystem/valuepairs) and matching them against the corresponding issuer DID attributes - A known special case for
roleCodewhich uses a direct attribute rather than theidentifierpattern
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 (
issuanceDateafternotBefore,expirationDatebeforenotAfter) → 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 DIDhttp://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
- did:x509 resolver (modify): incorporate certificate chain validation, trust anchor checks
- Key resolution (modify): add CRL check at reference time, credential-vs-certificate time validation
- Issuer attribute matching (new or modify
Verify): recursive identifier walk + roleCode special case, applied to any did:x509-issued credential - 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
Labels
Type
Fields
Priority
Activity
stevenvegt commentedon Mar 23, 2026
DID-Document and key resolution: how to deal with dates:
stevenvegt commentedon Jul 30, 2026
User story 6 states a solution rather than a requirement
For reference, story 6 reads:
As I read it, the intent is to prevent mass invalidation: without it, revoking a single certificate retroactively invalidates every credential ever issued under it, turning one revocation into an outage across that issuer's whole outstanding population.
I checked that against the UZI register CPS (EN, v12.2, 01-04-2026):
keyCompromiseand honour a reference time otherwise, but the UZI register does not publishreasonCode, so a compromise revocation is indistinguishable from a billing one. Any reference-time rule applies to both, which makes compromise unenforceable.Suggested restatement
That is the actual need, and it does not presuppose how we meet it.
Worth being explicit about the difficulty it exposes: "irrelevant to the credential's content" is precisely the distinction the CRL cannot express, per finding 2. So this requirement cannot be satisfied directly from revocation data. It can only be approximated, or met by changing something other than the validation rule.
One approximation that does not weaken compromise handling is bounding credential lifetime. RFC023 5.5 already caps
expirationDateat the certificate'snotAfter; a much shorter cap on top means an administrative revocation only affects credentials issued recently, so re-issuance is routine traffic rather than an incident. That keeps current-status checking, stays compliant with 4.9.6, and needs no timestamping infrastructure. It is one option rather than the requirement.Also needs reconciling
#4079 Part A says the CRL check happens "at reference time per PSA 10.4.3", but #4083's acceptance criteria only require it during
Resolve(), andpki.Validator.CheckCRL(chain []*x509.Certificate) error(pki/interface.go:74) has no time parameter. As written #4083 ships an unconditional current-status check, which per the above is the right behaviour, but the PRD and the ticket currently contradict each other and one should change.CPS evidence (revocation grounds, reason codes, relying-party obligation)
4.9.1, grounds for revocation. Subscriber-obliged grounds include "Termination of the subscriber's existence", "Termination of the relationship between the subscriber and certificate holder of a UZI-card", "Inaccuracies in, or changes to, the details shown on the certificates", "System/server no longer in use at the care institution" and "Permission to use the domain name is withdrawn". Register-initiated grounds add that "A care provider card [zorgverlenerpas] is withdrawn if the holder is no longer permitted to use the professional title, qualification title or the specialism included in the certificate", "A server certificate is withdrawn if it is not paid for by the set deadline", and revocation on observed inaccuracies "for example in the event of a name change".
Only two grounds concern compromise: "Observed or suspected misuse or compromise", and register-initiated revocation when "the private key belonging to the certificates ... has been compromised".
Note the professional-title ground is exactly the retirement case in the restated story.
No reason codes. The string
reasonCodedoes not appear anywhere in the document. 4.9.1 closes with "The reasons for each revocation initiated by the UZI register are documented and archived" — archived by the register, not published to relying parties.4.9.6, relying party obligation. "Trusting parties are obliged to check the current status (withdrawn/not withdrawn) of a certificate by consulting the most recently published CRL or via the OCSP facility. Trusting parties are also obliged to check the CRL's electronic signature, including the corresponding certification path."
Supporting details. 4.9.7: "Revoked certificates remain on the CRL even after the original validity date has passed", so revocation information never ages out and strict checking is always possible. 4.9.13 through 4.9.16: there is no suspension state. 4.9: "If a certificate is withdrawn, it cannot be declared valid again." Revocation is one-way and permanent.
Why an issuance-time reference is unsound regardless of the CA
issuanceDateis set by the issuer. The only constraints on it are the certificate's own validity window, since RFC023 5.5 requires it to fall at or afternotBeforeand capsexpirationDateat or beforenotAfter.So a party holding a compromised private key can choose an
issuanceDatebefore the revocation date and pass a reference-time check indefinitely, for as long as the certificate would otherwise have remained valid. The party whose key was compromised picks the moment at which their own key is evaluated.Reference-time validation is sound only when the reference time is attested by someone other than the signer, which is what RFC 3161 timestamp tokens are for. Adopting that would mean changing the credential format and adding TSA infrastructure, and would still not satisfy CPS 4.9.6.
This is not a defect in current behaviour:
CheckCRLtakes no reference time today, so a revoked certificate fails immediately whateverissuanceDateclaims. The risk would be introduced by implementing story 6.Open question on PSA 10.4.3, and an OCSP aside
Story 6 cites PSA 10.4.3 and I do not have access to the LSPxNuts PSA. If that document mandates issuance-time key validity as a conformance requirement, this stops being a design choice and becomes a conflict between the PSA and the UZI CPS, which should be resolved with whoever owns the PSA before #4083 is implemented.
Separately: the node consults CRLs only. The register also publishes status via OCSP per RFC 6960 (CPS 4.9.9), against hourly CRL issuance (4.9.7). If revocation freshness matters for a use case, that is an available improvement.