Skip to content

fix(vcr): bound negative OpenID4VCI identifier caching, guard against empty offers - #4470

Open
reinkrul wants to merge 4 commits into
masterfrom
fix/4469-tls-identifier-cache-empty-result
Open

fix(vcr): bound negative OpenID4VCI identifier caching, guard against empty offers#4470
reinkrul wants to merge 4 commits into
masterfrom
fix/4469-tls-identifier-cache-empty-result

Conversation

@reinkrul

@reinkrul reinkrul commented Sep 4, 2026

Copy link
Copy Markdown
Member

Related: #4469

Problem

tlsIdentifierResolver (vcr/openid4vci/identifiers.go) cached its resolution result as soon as it completed without erroring — including an empty string when neither the DID document nor TLS-certificate-derived candidates yielded a base URL. Once that happened, every later Resolve() call for that DID short-circuited on the cached empty value for the life of the process, even after the missing node-http-services-baseurl service was added, permanently breaking OpenID4VCI credential-offer delivery until restart.

Observed on the sender side (issuer), for a credential_offer sent with credential_issuer:"":

level=warning msg="Couldn't publish credential over OpenID4VCI, fallback to publish over Nuts network" credentialID="did:nuts:ISSUER_DID_REDACTED#REDACTED-CREDENTIAL-UUID" error="unable to offer the credential over OpenID4VCI to (wallet: https://receiver.example.com/n2n/identity/did:nuts:RECEIVER_DID_REDACTED): unable to offer credential (client-metadata-url=https://receiver.example.com/n2n/identity/did:nuts:RECEIVER_DID_REDACTED/openid4vci/credential_offer): offer credential error: unexpected http response code (...): 500" module=VCR

Observed on the receiver side, rejecting that same offer:

time="2026-09-03T08:16:47Z" level=error msg="HandleCredentialOffer failed" error="server_error - unable to create issuer client: empty Credential Issuer Identifier" module=VCR/OpenID4VCI operation=HandleCredentialOffer operationID=HandleCredentialOffer requestURI="/n2n/identity/did:nuts:RECEIVER_DID_REDACTED/openid4vci/credential_offer?credential_offer=..." user="<nil>"

Fix

Bound the negative cache instead of removing it. An earlier version of this fix stopped caching empty results at all, but this resolver can be called on every OpenID4VCI request, so re-running resolution (including the DID document lookup) on every single call for a DID that never gets fixed isn't free — the original negative caching was likely deliberate. Instead, reuse the existing lastAttempt/tlsAttemptInterval throttle (already used to rate-limit the expensive TLS-certificate-derived resolution) to also bound how long an empty result is treated as cached, rather than adding a separate cache window. A successful (non-empty) identifier is still cached indefinitely, unchanged.

Guard against sending a broken offer. Nothing previously stopped an OpenID4VCI credential offer from being sent with an empty credential_issuer — which is what produced the receiver-side "empty Credential Issuer Identifier" rejection shown above. Introduced openid4vci.ErrIdentifierNotConfigured, returned when resolution succeeds but yields no identifier, and issueUsingOpenID4VCI now treats it like an unsupported wallet (fallback to the network) instead of constructing and sending the broken offer.

Warn the operator, unlike the unsupported-wallet case. An unsupported wallet is the other party's problem — nothing this operator can act on, so it stays silent. A missing node-http-services-baseurl service is this node's own misconfiguration, so it now logs a Warn pointing the operator at what to search the documentation for, instead of failing the same silent way.

Test plan

  • TestTLSIdentifierResolver/empty_result_is_cached_briefly,_not_forever (new) — asserts a cache hit on an immediate second call, then a re-check once the throttle window elapses.
  • Test_vcr_GetOIDCIssuer/found_DID,_owned,_but_no_identifier_configured (new) — asserts ErrIdentifierNotConfigured when resolution yields an empty identifier.
  • Test_issuer_Issue/OpenID4VCI/ok_-_OpenID4VCI_issuer_identifier_not_(yet)_configured_-_fallback_to_network (new) — asserts a quiet fallback to network publish, no offer sent, and the new Warn log.
  • Existing vcr, vcr/issuer, vcr/openid4vci suites still pass.

Assisted by AI

…fier

tlsIdentifierResolver cached a resolution result as soon as it succeeded
without erroring, including an empty string when no base-URL service or
TLS-derived candidate could be found. Once that happened, every later
Resolve() call for that DID short-circuited on the cached empty value for
the life of the process, even after the missing node-http-services-baseurl
service was added, permanently breaking OpenID4VCI credential-offer
delivery until restart.

Only cache a non-empty identifier, and only treat a non-empty cached value
as a hit, so resolution is retried on every call until it actually
succeeds.

Assisted by AI
@qltysh

qltysh Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: C
vcr/vcr.go100.0%
Coverage rating: B Coverage rating: B
vcr/issuer/issuer.go100.0%
Coverage rating: B Coverage rating: B
vcr/openid4vci/identifiers.go100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Address review feedback on the previous commit: caching nothing at all
for an unresolved identifier would re-run resolution (including the DID
document lookup) on every single call for a DID that never gets fixed,
since this resolver can be invoked on every OpenID4VCI request. That
negative caching was likely deliberate, not a bug.

Reuse the existing lastAttempt/tlsAttemptInterval throttle (already used
to rate-limit the expensive TLS-certificate-derived resolution) to also
bound how long an empty result is treated as cached, instead of adding a
separate cache window. A successful (non-empty) identifier is still
cached indefinitely, unchanged.

Also close the actual sending-side gap: nothing previously stopped an
OpenID4VCI credential offer from being sent with an empty
`credential_issuer`, which is what produced the receiver-side "empty
Credential Issuer Identifier" rejection in the first place. Introduce
openid4vci.ErrIdentifierNotConfigured, returned when resolution succeeds
but yields no identifier, and have issueUsingOpenID4VCI treat it like an
unsupported wallet (quiet fallback to the network, no error) instead of
constructing and sending a broken offer.

Assisted by AI
@reinkrul reinkrul changed the title fix(vcr): don't permanently cache an empty OpenID4VCI base-URL identifier fix(vcr): bound negative OpenID4VCI identifier caching, guard against empty offers Sep 4, 2026
Unlike an unsupported wallet (the other party's problem, no action for
this operator), a missing node-http-services-baseurl service is this
node's own misconfiguration and needs the operator's attention. Log a
Warn pointing them at what to search the documentation for, rather than
staying silent like the unsupported-wallet case.

Assisted by AI
@reinkrul
reinkrul marked this pull request as ready for review September 4, 2026 08:22
reinkrul added a commit that referenced this pull request Sep 7, 2026
…nHammer mechanism

- Lead with "server-to-server issuance" as the durable concept rather than
  OpenID4VCI, since the underlying protocol may be swapped out later; frame
  the gRPC/Nuts network as the (soon to be legacy) fallback we're moving
  away from, rather than centering the explanation on it.
- List the concrete /n2n endpoints server-to-server issuance needs
  reachable, and note the whole /n2n prefix must be proxied through, not
  just these specific paths.
- Correct the automatic-registration description: GoldenHammer does not
  use config.URL (that's v6+; GoldenHammer predates it, from v5.4). It
  actually probes each hostname in the node's own TLS certificate with a
  HEAD request to <hostname>/n2n/identity/<did>/.well-known/openid-credential-issuer,
  registering the first one that responds 200 with a JSON content type
  (verified against golden_hammer/module.go's tryResolveURL and
  vcr/openid4vci/identifiers.go's resolveFromCertificate/testIdentifier).
- Dropped the closing line quoting #4470's warning message: that PR hasn't
  merged to master yet, so the message doesn't exist there.

Assisted by AI
reinkrul added a commit that referenced this pull request Sep 7, 2026
…etup step (#4475)

* docs(deployment): document node-http-services-baseurl as a required setup step

Nothing currently documents this service at all, despite it being required
for a did:nuts DID to be reachable over OpenID4VCI - it's only ever set
automatically by GoldenHammer, a periodic best-effort auto-heal module
that's scheduled for removal (#2318) and can't guarantee the service is
present the moment it's actually needed. A network-wide audit (see #4469)
found ~92% of DID documents missing it.

Explains what it's for, how to register it via the DIDMan API for both a
vendor DID (concrete URL) and a subject DID referencing a vendor's
NutsComm service (matching reference, not a duplicate URL), and the log
warning (#4470) that names an affected DID - which itself now points
operators at this section by name.

Verified with a clean sphinx build (0 new warnings; the 4 pre-existing
ones are unrelated, in release_notes.rst/discovery.rst).

Assisted by AI

* docs(deployment): reframe as server-to-server issuance, correct GoldenHammer mechanism

- Lead with "server-to-server issuance" as the durable concept rather than
  OpenID4VCI, since the underlying protocol may be swapped out later; frame
  the gRPC/Nuts network as the (soon to be legacy) fallback we're moving
  away from, rather than centering the explanation on it.
- List the concrete /n2n endpoints server-to-server issuance needs
  reachable, and note the whole /n2n prefix must be proxied through, not
  just these specific paths.
- Correct the automatic-registration description: GoldenHammer does not
  use config.URL (that's v6+; GoldenHammer predates it, from v5.4). It
  actually probes each hostname in the node's own TLS certificate with a
  HEAD request to <hostname>/n2n/identity/<did>/.well-known/openid-credential-issuer,
  registering the first one that responds 200 with a JSON content type
  (verified against golden_hammer/module.go's tryResolveURL and
  vcr/openid4vci/identifiers.go's resolveFromCertificate/testIdentifier).
- Dropped the closing line quoting #4470's warning message: that PR hasn't
  merged to master yet, so the message doesn't exist there.

Assisted by AI

* docs(deployment): cross-reference node-http-services-baseurl from the /n2n endpoint description

Point operators reading about the /n2n endpoint (Legacy Endpoints section)
at the node-http-services-baseurl setup section, since server-to-server
credential issuance for did:nuts DIDs runs over that same endpoint.

Assisted by AI

* docs(deployment): move node-http-services-baseurl section below Strict mode

Moved out from under "Server options" (where it read as a sub-topic of the
did:nuts/gRPC options table) to its own top-level section after Strict
mode, matching Secrets/Strict mode's heading level.

Assisted by AI

* docs(deployment): address review feedback from #4475

- "service" -> "service endpoint"/"DID Document service endpoint"
  throughout, per review: "service" alone read like an actual service you
  need to run, not a DID Document term.
- Reworded the automatic-registration paragraph per review: leads with
  what's added automatically (to ease configuration) and why it can fail,
  rather than "normally registered automatically... as long as...".
- Corrected the gRPC/DAG fallback description. The reviewer's suggested
  "publishes an encrypted reference to a credential" isn't accurate either
  - what's actually encrypted is the transaction's recipient list (PAL),
  not the credential or a reference to it; the credential payload itself
  is never published to the network, it's fetched separately afterward
  over an authenticated connection between the specific nodes involved.

Assisted by AI
reinkrul added a commit that referenced this pull request Sep 7, 2026
…etup step (#4475) (#4480)

* docs(deployment): document node-http-services-baseurl as a required setup step

Nothing currently documents this service at all, despite it being required
for a did:nuts DID to be reachable over OpenID4VCI - it's only ever set
automatically by GoldenHammer, a periodic best-effort auto-heal module
that's scheduled for removal (#2318) and can't guarantee the service is
present the moment it's actually needed. A network-wide audit (see #4469)
found ~92% of DID documents missing it.

Explains what it's for, how to register it via the DIDMan API for both a
vendor DID (concrete URL) and a subject DID referencing a vendor's
NutsComm service (matching reference, not a duplicate URL), and the log
warning (#4470) that names an affected DID - which itself now points
operators at this section by name.

Verified with a clean sphinx build (0 new warnings; the 4 pre-existing
ones are unrelated, in release_notes.rst/discovery.rst).

Assisted by AI

* docs(deployment): reframe as server-to-server issuance, correct GoldenHammer mechanism

- Lead with "server-to-server issuance" as the durable concept rather than
  OpenID4VCI, since the underlying protocol may be swapped out later; frame
  the gRPC/Nuts network as the (soon to be legacy) fallback we're moving
  away from, rather than centering the explanation on it.
- List the concrete /n2n endpoints server-to-server issuance needs
  reachable, and note the whole /n2n prefix must be proxied through, not
  just these specific paths.
- Correct the automatic-registration description: GoldenHammer does not
  use config.URL (that's v6+; GoldenHammer predates it, from v5.4). It
  actually probes each hostname in the node's own TLS certificate with a
  HEAD request to <hostname>/n2n/identity/<did>/.well-known/openid-credential-issuer,
  registering the first one that responds 200 with a JSON content type
  (verified against golden_hammer/module.go's tryResolveURL and
  vcr/openid4vci/identifiers.go's resolveFromCertificate/testIdentifier).
- Dropped the closing line quoting #4470's warning message: that PR hasn't
  merged to master yet, so the message doesn't exist there.

Assisted by AI

* docs(deployment): cross-reference node-http-services-baseurl from the /n2n endpoint description

Point operators reading about the /n2n endpoint (Legacy Endpoints section)
at the node-http-services-baseurl setup section, since server-to-server
credential issuance for did:nuts DIDs runs over that same endpoint.

Assisted by AI

* docs(deployment): move node-http-services-baseurl section below Strict mode

Moved out from under "Server options" (where it read as a sub-topic of the
did:nuts/gRPC options table) to its own top-level section after Strict
mode, matching Secrets/Strict mode's heading level.

Assisted by AI

* docs(deployment): address review feedback from #4475

- "service" -> "service endpoint"/"DID Document service endpoint"
  throughout, per review: "service" alone read like an actual service you
  need to run, not a DID Document term.
- Reworded the automatic-registration paragraph per review: leads with
  what's added automatically (to ease configuration) and why it can fail,
  rather than "normally registered automatically... as long as...".
- Corrected the gRPC/DAG fallback description. The reviewer's suggested
  "publishes an encrypted reference to a credential" isn't accurate either
  - what's actually encrypted is the transaction's recipient list (PAL),
  not the credential or a reference to it; the credential payload itself
  is never published to the network, it's fetched separately afterward
  over an authenticated connection between the specific nodes involved.

Assisted by AI
reinkrul added a commit that referenced this pull request Sep 8, 2026
…etup step (#4475) (#4481)

* docs(deployment): document node-http-services-baseurl as a required setup step

Nothing currently documents this service at all, despite it being required
for a did:nuts DID to be reachable over OpenID4VCI - it's only ever set
automatically by GoldenHammer, a periodic best-effort auto-heal module
that's scheduled for removal (#2318) and can't guarantee the service is
present the moment it's actually needed. A network-wide audit (see #4469)
found ~92% of DID documents missing it.

Explains what it's for, how to register it via the DIDMan API for both a
vendor DID (concrete URL) and a subject DID referencing a vendor's
NutsComm service (matching reference, not a duplicate URL), and the log
warning (#4470) that names an affected DID - which itself now points
operators at this section by name.

Verified with a clean sphinx build (0 new warnings; the 4 pre-existing
ones are unrelated, in release_notes.rst/discovery.rst).

Assisted by AI

* docs(deployment): reframe as server-to-server issuance, correct GoldenHammer mechanism

- Lead with "server-to-server issuance" as the durable concept rather than
  OpenID4VCI, since the underlying protocol may be swapped out later; frame
  the gRPC/Nuts network as the (soon to be legacy) fallback we're moving
  away from, rather than centering the explanation on it.
- List the concrete /n2n endpoints server-to-server issuance needs
  reachable, and note the whole /n2n prefix must be proxied through, not
  just these specific paths.
- Correct the automatic-registration description: GoldenHammer does not
  use config.URL (that's v6+; GoldenHammer predates it, from v5.4). It
  actually probes each hostname in the node's own TLS certificate with a
  HEAD request to <hostname>/n2n/identity/<did>/.well-known/openid-credential-issuer,
  registering the first one that responds 200 with a JSON content type
  (verified against golden_hammer/module.go's tryResolveURL and
  vcr/openid4vci/identifiers.go's resolveFromCertificate/testIdentifier).
- Dropped the closing line quoting #4470's warning message: that PR hasn't
  merged to master yet, so the message doesn't exist there.

Assisted by AI

* docs(deployment): cross-reference node-http-services-baseurl from the /n2n endpoint description

Point operators reading about the /n2n endpoint (Legacy Endpoints section)
at the node-http-services-baseurl setup section, since server-to-server
credential issuance for did:nuts DIDs runs over that same endpoint.

Assisted by AI

* docs(deployment): move node-http-services-baseurl section below Strict mode

Moved out from under "Server options" (where it read as a sub-topic of the
did:nuts/gRPC options table) to its own top-level section after Strict
mode, matching Secrets/Strict mode's heading level.

Assisted by AI

* docs(deployment): address review feedback from #4475

- "service" -> "service endpoint"/"DID Document service endpoint"
  throughout, per review: "service" alone read like an actual service you
  need to run, not a DID Document term.
- Reworded the automatic-registration paragraph per review: leads with
  what's added automatically (to ease configuration) and why it can fail,
  rather than "normally registered automatically... as long as...".
- Corrected the gRPC/DAG fallback description. The reviewer's suggested
  "publishes an encrypted reference to a credential" isn't accurate either
  - what's actually encrypted is the transaction's recipient list (PAL),
  not the credential or a reference to it; the credential payload itself
  is never published to the network, it's fetched separately afterward
  over an authenticated connection between the specific nodes involved.

Assisted by AI
@qltysh

qltysh Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

1 new issue

Tool Category Rule Count
qlty Structure Function with many returns (count = 7): issueUsingOpenID4VCI 1

Resolve module path conflicts from the /v6 module rename.

Assisted-by: AI
@stevenvegt
stevenvegt force-pushed the fix/4469-tls-identifier-cache-empty-result branch from 6ae6e03 to 5c7e6ec Compare September 9, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants