Skip to content

Latest commit

 

History

History
264 lines (224 loc) · 14.2 KB

File metadata and controls

264 lines (224 loc) · 14.2 KB

HTTPS-based Issuer Identifiers

VCVerifier accepts credential issuer identifiers that are HTTPS URLs (https://issuer.example.com) alongside the DID methods it already supports (did:key, did:web, did:jwk, did:elsi). An issuer identifier is treated as a generic URI: whether it is a DID or an HTTPS URL only decides how the signing key is discovered, not what the rest of the verification chain does with it.

Why

Not every credential issuer publishes a DID document. The SD-JWT VC and OpenID4VCI specifications identify an issuer by its HTTPS URL and publish the signing keys under well-known endpoints. Requiring a DID would exclude every issuer that follows those specs.

Key discovery

verifier/https_issuer_resolver.go implements HttpsIssuerResolver. Given an issuer URL it resolves a JWKS through two paths, in order:

  1. SD-JWT VC Issuer Metadata (primary, draft-ietf-oauth-sd-jwt-vc §5.2): the well-known segment is inserted between host and path, so issuer https://example.com/tenant1 is looked up at https://example.com/.well-known/jwt-vc-issuer/tenant1. The metadata provides either an inline jwks or a jwks_uri.
  2. OpenID4VCI Credential Issuer Metadata (fallback): OpenID4VCI §12.2.4 appends its segment instead, so the same issuer is looked up at https://example.com/tenant1/.well-known/openid-credential-issuer. Its authorization_servers are resolved via /.well-known/oauth-authorization-server (RFC 8414 §3.1, inserted like the primary path), whose metadata provides the jwks_uri.

The two placement conventions genuinely differ; wellKnownURLInserted and wellKnownURLAppended implement them separately.

The issuer field in the fetched metadata must equal the issuer URL the lookup started from (RFC 8414 §3.3) — on every hop: the SD-JWT VC metadata, the OpenID4VCI metadata (issuer or credential_issuer) and the authorization server metadata. Both sides are canonicalized the same way (a trailing slash is ignored), so an issuer whose identifier ends in / is neither rejected against its own metadata nor able to pass as another one. A mismatch fails with ErrorIssuerMismatch and is not retried through the other path: an endpoint that answers while claiming a different identity is a security signal, not a reason to follow one more hop.

An issuer identifier carries no query and no fragment (RFC 8414 Section 2), and parseIssuerURL rejects one that does. Neither would reach the well-known URL built from it, so accepting them would let https://issuer.example?1, ?2, ... address one endpoint under unboundedly many identifiers — a counter in the query would walk past the failure cache.

Resolved key sets are cached under issuerCacheKey: the identifier with only its scheme and host lowercased (both are case-insensitive per RFC 3986) and a trailing slash dropped. Everything else — the case of the path, its percent-encoding — keeps two identifiers apart, since the cached path returns before any identity check runs. %2F inside a segment is in particular not a separator; the same escaped form is used when the well-known URL is built, so an issuer with an encoded slash discovers at its own endpoint rather than another's.

Key sets are cached for DefaultJwksCacheTTL (15 minutes), or for the shorter lifetime the origin declares via Cache-Control: max-age — an origin may shorten its keys' cache lifetime but not extend it beyond the configured TTL. An origin that declares a lifetime of zeromax-age=0, no-store or no-cache — is honoured by not storing the entry at all, and by dropping any entry an earlier fetch left behind; go-cache reads a zero TTL as "use the default expiration", so passing it on would cache for the full 15 minutes instead. Resolution failures are cached too (DefaultJwksFailureCacheTTL, 30s), so a flood of tokens naming an unresolvable issuer cannot be turned into a flood of outbound requests. CachingHttpsIssuerResolver is created once in InitPresentationParser and shared — via GetHttpsIssuerResolver() — by every component that resolves issuer keys, so one cache serves the JWT path, the JSON-LD proof path and status-list verification.

Key rotation

A cached key set that does not contain the requested kid triggers one refetch per MinJwksRefetchInterval (1 minute). A rotated key is therefore picked up without waiting out the cache TTL, while unknown key ids cannot drive the outbound request rate.

A refetch that fails keeps the cached keys — the issuer answered a moment ago, and replacing them with a failure would break verification for keys that still work — but it still moves the refetch window (postponeRefetch), carrying over what is left of the entry's lifetime so writing it back does not extend the cache indefinitely. Without that, the rate limit would be defeated in exactly the case it exists for.

Outbound request restrictions

Everything the resolver fetches is chosen by whoever presented the token: the issuer URL comes from the token, and the next hops (jwks_uri, authorization_servers) come from a document that URL serves. Proof verification runs before any trust-registry check, so this is reachable from unauthenticated input and is confined accordingly:

  • Every address a request would connect to is classified before the connection is made: loopback, the RFC 1918 / RFC 4193 private ranges, link-local (including 169.254.169.254), multicast and a handful of reserved ranges are refused with ErrorAddressNotAllowed. This is the only restriction that also covers the first hop — the issuer identifier itself, which arrives in an unverified token — so iss: "https://10.0.0.5:8443/x" cannot make the verifier probe the internal network. The check happens in the dialer, on the resolved address, and the connection is then made to the address that was checked, so a name answering differently the second time (DNS rebinding) gains nothing. Set verifier.httpsIssuerAllowPrivateNetworks: true for a deployment whose issuers genuinely live in the verifier's own network.
  • A URL taken from a metadata document must use the same scheme as the issuer (no https→http downgrade) and live on the issuer's own host, unless the operator listed the host in verifier.httpsIssuerAllowedHosts. Anything else fails with ErrorMetadataURLNotAllowed.
  • Redirects may not leave the origin of the original request, and at most maxMetadataRedirects (5) are followed.
  • Response bodies are read through an io.LimitReader bounded to maxMetadataResponseBytes (1 MiB).
  • At most maxAuthorizationServers (5) entries of authorization_servers are tried. The list comes from the issuer, and host pinning does not help here — the issuer's own host is exactly the one an attacker controls — so an unbounded walk would let one metadata document drive thousands of requests.
  • Each request is bounded by httpClientTimeout (10s) and the whole resolution by resolutionTimeout (30s), so the number of hops a document asks for cannot extend the total. The resolver honours a caller context once one is threaded down to it; today all three call sites still pass context.Background(), so the two timeouts are the only bound in practice.

Where an egress proxy is configured (HTTP_PROXY / HTTPS_PROXY), the address guard applies to the connection to the proxy and the egress policy is the proxy's.

verifier:
  # only needed when an issuer's JWKS or authorization server lives on a
  # different host than the issuer identifier itself
  httpsIssuerAllowedHosts:
    - "keys.example.com"
  # only needed when the issuers themselves live inside the verifier's own
  # network; the default refuses loopback, private and link-local addresses
  httpsIssuerAllowPrivateNetworks: false

Where HTTPS issuers are resolved

Path Component Identifier it resolves
JWT VC / VP signatures verifier/jwt_proof_checker.go the iss claim
JSON-LD Linked Data Proofs verifier/ld_proof_checker.go the proof's verificationMethod
Status list JWTs verifier/credential_status_client.go the status list's iss claim

Each of these dispatches on isHttpsIssuer() and falls back to DID resolution for everything else. When an HTTPS identifier is encountered but no resolver is configured, verification fails closed with ErrorHttpsIssuerNotSupported — it is never silently downgraded.

kid selection

The JWT paths pass the JWS kid header straight through to the resolver.

The JSON-LD path cannot: a verificationMethod is a URI, and it is the fragment that names the key inside the issuer's JWKS. httpsJwksKeyId() extracts it, so https://issuer.example.com#key-1 selects the JWKS entry with kid key-1.

When no kid is available — a fragment-less verificationMethod, or a JWS without a kid header — the resolver returns every signature-capable key of the set and the caller accepts the first one that verifies the signature. Picking keySet.Key(0) would make verification depend on JWKS ordering. Keys marked use: enc, or whose key_ops exclude verify, are never candidates.

A kid is a hint (RFC 7515 Section 4.1.4), so a JWKS key that declares no kid is a candidate for a JWS that names one, whenever no key carries the named id — an issuer publishing a single unlabelled key would otherwise never verify. Keys carrying a different kid are not returned: that a key set labels its keys and labels none of them the way the JWS does is the signal that the set is stale, and it is what makes the rotation refetch above work.

Every JWS the verifier checks — including the status-list x5c fallback, which takes its key from a certificate rather than from a JWKS — goes through verifyJWSWithCandidateKeys. The algorithm is taken from the JWS header but pinned before use: it must be in the allowlist (verifier/jws_verification.go — the RSA, PSS, ECDSA and EdDSA families; never none or the symmetric HS* family), and it must match the alg the JWKS entry declares, when it declares one.

Trust validation

Trust lists are not extended for HTTPS issuers, and their entries are never read as issuer identities. A trustedIssuersLists / trustedParticipantsLists entry is always the address of a trusted-issuers-list API to query — EBSI (v3/v4, v5) or Gaia-X — and the issuer identifier of the credential is looked up there, whether it is a DID or an HTTPS URL:

trustedIssuersLists:
  -   type: ebsi
      url: https://til-pdc.ebsi.fiware.dev

So an HTTPS issuer becomes trusted by being registered in one of the configured registries, exactly like a DID-based one. Nothing in verifier/trustedissuer.go or verifier/trustedparticipant.go branches on the shape of the identifier.

Two consequences worth stating explicitly:

  • A registry address is not an issuer identity. A credential whose issuer happens to equal a configured registry URL gets no special treatment; it is looked up like any other and rejected unless the registry knows it.
  • The wildcard keeps its meaning. url: "*" in a trusted-issuers list waives the registry lookup for that credential type — for every issuer, DID- or HTTPS-based alike.

tir.issuerPathSegment places the identifier into the registry lookup URL. It percent-encodes only the characters that would otherwise end the path segment (/, ?, #), so https://issuer.example.com/tenant1 is addressed as one issuer, while a DID reaches the registry byte for byte as configured — including the %3A a did:web with a port already carries, which a general-purpose escaper would turn into %253A.

Gaia-X entries resolve the issuer as a DID, so an HTTPS issuer simply fails to resolve there and is not trusted through that path.

Security notes

  • Transport is the trust anchor. Key discovery relies on TLS for the well-known endpoints, so an HTTPS issuer is exactly as trustworthy as its certificate and DNS. This is the model the SD-JWT VC and OpenID4VCI specs assume; it is weaker than a DID document anchored in a registry, which is why an HTTPS issuer still has to be registered in a trusted-issuers registry (or covered by a wildcard) before it is accepted.
  • No verification relationships. A JWKS has no authentication / assertionMethod distinction, so the relationship enforcement ResolveKeyForRelationship applies to DID documents cannot apply here. The LD-proof path logs that it cannot enforce the relationship and accepts the key. The guards that do still apply are the proofPurpose assertion and the binding of the proof signer to the document's issuer / holder, so a key is never accepted for an unrelated identity. This mirrors how a DID document that declares no relationships is treated.
  • Status lists stay bound to their issuer. assertStatusListIssuer compares issuer strings, so an HTTPS-issued status list must be issued by the same HTTPS issuer as the credential that referenced it.
  • Key discovery is confined. See Outbound request restrictions above. The first hop is a host the token names, so it is bounded by the address guard alone: it may be any public address, but never one inside the deployment's own network. Every hop after it is additionally pinned to the issuer's scheme and host (or an explicitly allowed one), with origin-bound redirects, bounded bodies and cached failures. What remains is that a token can make the verifier issue a GET to a public URL of the sender's choosing, once per DefaultJwksFailureCacheTTL.

Known gaps

  • The JSON-LD path requires the verificationMethod to be the issuer URL plus a fragment. A verificationMethod that is a different URL under the same origin (e.g. https://issuer.example.com/keys/1 for issuer https://issuer.example.com) is rejected by the signer-binding check.
  • An issuer whose JWKS or authorization server lives on another host is only resolvable after that host is added to verifier.httpsIssuerAllowedHosts. The default confines discovery to the issuer's own origin.
  • An issuer inside the verifier's own network is only resolvable after verifier.httpsIssuerAllowPrivateNetworks is enabled. The switch is global: it cannot be granted to one issuer and withheld from another.