Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5b62b6e
Replace TIR v5 plan with correct HTTPS-based issuer identifiers plan
Aug 26, 2026
f9cb9c9
Agent general-agent-4: work on ticket #53
Aug 26, 2026
18a2537
Address review feedback on PR #7
Aug 26, 2026
32d092e
Address review feedback on PR #7
Aug 26, 2026
8619b8b
Merge pull request 'Ticket #53: Implementation Plan' (#7) from ticket…
Aug 26, 2026
1a898d0
Add HTTPS issuer metadata resolver with JWKS key resolution
Aug 26, 2026
c9b964d
Merge pull request 'Ticket #53 - Step 1: Implement HTTPS issuer metad…
Aug 26, 2026
8091b3d
Integrate HTTPS issuer key resolution into JWTProofChecker
Aug 26, 2026
45713c4
Merge pull request 'Ticket #53 - Step 2: Integrate HTTPS issuer key r…
Aug 26, 2026
c88abc6
Treat issuer identifiers as URIs in trust validation
Aug 26, 2026
20df118
Merge pull request 'Ticket #53 - Step 3: Treat issuer identifiers as …
Aug 26, 2026
5e858c1
Add end-to-end tests and config fixtures for HTTPS issuer support
Aug 26, 2026
140ecee
Merge pull request 'Ticket #53 - Step 4: Support https based client i…
Aug 26, 2026
0907c91
Merge upstream/main (ticket-54 JSON-LD proof verification) into ticke…
wistefan Aug 28, 2026
03287aa
Extend HTTPS issuer support to the paths added by upstream ticket-54
wistefan Aug 28, 2026
3ed45fc
Merge pull request 'Ticket #53 - Rebase onto upstream main and extend…
Aug 28, 2026
01ee48b
some fixes
wistefan Aug 28, 2026
8b3a16a
some fixes
wistefan Aug 28, 2026
7eccd90
some fixes
wistefan Aug 28, 2026
d4f762c
fix linting
wistefan Aug 28, 2026
df172c8
pr fixes
wistefan Sep 1, 2026
cd3ee28
fix it
wistefan Sep 1, 2026
11993a8
Merge branch 'main' into ticket-53/work
wistefan Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

# Review artifacts, kept locally
review.md
Comment thread
vramperez marked this conversation as resolved.
Outdated
24 changes: 22 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ Key config sections: `server` (port, timeouts, template/static dirs), `logging`,
- `compliance.go` — Policy compliance checking (signatures, dates, etc.)
- `holder.go` — Holder verification
- `gaiax.go` — Gaia-X compliance checks
- `jwt_proof_checker.go` — JWT signature verification via DID-resolved keys; also handles did:elsi via JAdES
- `ld_proof_checker.go` — JSON-LD Linked Data Proof verification (`JsonWebSignature2020`): resolves `verificationMethod`, binds the signing key to the credential issuer / presentation holder, enforces the proof purpose
- `jwt_proof_checker.go` — JWT signature verification via DID-resolved keys; also handles did:elsi via JAdES and HTTPS-based issuers via `HttpsIssuerResolver`
- `ld_proof_checker.go` — JSON-LD Linked Data Proof verification (`JsonWebSignature2020`): resolves `verificationMethod` (DID URL or https:// URL), binds the signing key to the credential issuer / presentation holder, enforces the proof purpose
- `key_resolver.go` — Shared DID→key resolution, including verification-relationship enforcement (`authentication` / `assertionMethod`)
- `https_issuer_resolver.go` — Key discovery for HTTPS-based issuer identifiers via `/.well-known/jwt-vc-issuer` (SD-JWT VC) with an OpenID4VCI + RFC 8414 fallback, plus a per-issuer JWKS cache
- `credentialsConfig.go` — Credential configuration management

- **`openapi/`** — HTTP handlers generated from OpenAPI spec (`api/api.yaml`). Routes defined in `routers.go`. Handlers in `api_api.go` (token, authorization, authentication) and `api_frontend.go` (frontend endpoints, WebSocket polling).
Expand Down Expand Up @@ -119,8 +120,27 @@ JSON-LD (`ldp_vc`) presentations and credentials are cryptographically verified
- The security-relevant contexts are vendored in `common/contexts/` and served by `common.NewEmbeddedContextLoader`, so verification never depends on the network.
- `m2m.verificationMethod` has no default and must be an absolute DID URL — `InitM2MTokenProvider` fails at startup otherwise, since a relative reference can never produce a valid proof. `tir.signerForKeyType` keeps the signer and the advertised JWS algorithm in sync (`RSARS256` → PKCS#1 v1.5, `RSAPS256` → PSS).

## HTTPS-based Issuer Identifiers

Credential issuers may be identified by an HTTPS URL instead of a DID — see `docs/https-issuer-identifiers.md` for the full design. In short:

- An issuer identifier is treated as a generic URI. `isHttpsIssuer()` decides whether the key is discovered via `did.Registry` or via `verifier/https_issuer_resolver.go` (well-known metadata → JWKS).
- Three paths dispatch on it: the JWT `iss` claim (`jwt_proof_checker.go`), the JSON-LD proof `verificationMethod` (`ld_proof_checker.go`) and the status list `iss` claim (`credential_status_client.go`). Each fails closed with `ErrorHttpsIssuerNotSupported` when no resolver is configured.
- The resolver is created once in `InitPresentationParser` and shared via `GetHttpsIssuerResolver()`, so a single JWKS cache serves all three paths.
- Well-known paths follow their spec: SD-JWT VC and RFC 8414 **insert** the segment between host and path (`wellKnownURLInserted`), OpenID4VCI **appends** it (`wellKnownURLAppended`).
- The `issuer` of fetched metadata must equal the issuer URL the lookup started from (RFC 8414 §3.3) on every hop, compared after the same canonicalization on both sides. A mismatch surfaces as `ErrorIssuerMismatch` and is never retried through the other discovery path.
- Everything after the first request is named by attacker-influenced metadata, so `allowedMetadataURL` pins `jwks_uri` / `authorization_servers` to the issuer's scheme and host (plus `verifier.httpsIssuerAllowedHosts`), redirects may not leave the origin, bodies are `io.LimitReader`-bounded, and failures are negatively cached.
- `ResolveIssuerKeys` returns **candidate** keys: the kid-matching key, or every signature-capable key when no kid is available. Callers try each; `verifyJWSWithCandidateKeys` (`verifier/jws_verification.go`) pins the `alg` to an allowlist and to the key's own `alg`.
- A cached key set missing the requested kid triggers one refetch per `MinJwksRefetchInterval`, so key rotation is picked up without waiting out the TTL. A failed refetch keeps the cached keys but re-arms the window (`postponeRefetch`, preserving the remaining TTL).
- Resolution is bounded twice: `httpClientTimeout` per request and `resolutionTimeout` for the whole discovery, with at most `maxAuthorizationServers` entries tried.
- `issuerCacheKey` lowercases only scheme and host and trims a trailing slash; the path keeps its case **and its percent-encoding** (`EscapedPath`), as does the well-known URL — decoding `%2F` would let one identifier read another's cached keys, before any identity check runs.
- In the JSON-LD path the JWKS `kid` is the **fragment** of the `verificationMethod` (`httpsJwksKeyId`), not the whole URI.
- Trust validation does **not** branch on the identifier shape. A trust-list entry is always a registry endpoint (`ebsi`, `ebsi-v5`, `gaia-x`), never an issuer identity, so an HTTPS issuer is trusted by being registered in one of the configured trusted-issuers-list APIs — or by the `*` wildcard. `tir.issuerPathSegment` percent-encodes only `/`, `?` and `#` so an HTTPS identifier stays one path segment while an already-encoded `did:web` is not encoded twice.

## Known Gaps

- **HTTPS issuers cannot have verification relationships enforced.** A JWKS has no `authentication` / `assertionMethod` distinction, so the LD-proof path logs a warning and accepts the key; `proofPurpose` and issuer/holder binding remain enforced.
- **An HTTPS issuer whose JWKS or authorization server lives on another host** is unresolvable until that host is added to `verifier.httpsIssuerAllowedHosts`.
- **`validationMode: combined` and `jsonLd`** do not perform real JSON-LD validation — they only check that issuer and type fields are present. They are deprecated but still accepted.
- **Verification relationships are only enforced when the DID document declares them.** A `did:web` document that lists `verificationMethod` but neither `authentication` nor `assertionMethod` falls back to the flat method list with a warning.
- **Data Integrity suites other than `JsonWebSignature2020`** (`proofValue`-based cryptosuites) are parsed but not verified.
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ verifier:
# presentation stays replayable on the grants that have no server-issued
# nonce (`vp_token` and token-exchange). Set to 0 to accept any age.
ldProofMaxAge: 300
# Hosts that the metadata of an HTTPS-based credential issuer may point to
# (jwks_uri, authorization_servers) in addition to the issuer's own host.
# Empty by default, which confines key discovery to the issuer's own origin
# — see "Trusted issuers and participants" below.
httpsIssuerAllowedHosts: []

# configuration of the service to retrieve configuration for
configRepo:
Expand Down Expand Up @@ -237,6 +242,37 @@ configRepo:
'sd+jwt-vc':
alg: ES256
```
#### Trusted issuers and participants

Every entry of `trustedIssuersLists` and `trustedParticipantsLists` is the
address of a registry to query — never the identity of an issuer. The `type`
selects the API to talk to:

| `type` | `url` |
| --- | --- |
| `ebsi` | an EBSI Trusted Issuers Registry (v3/v4) |
| `ebsi-v5` | an EBSI Trusted Issuers Registry (v5) |
| `gaia-x` | a Gaia-X registry (participants only) |

A bare string entry (the legacy format, `- https://tir-pdc.ebsi.fiware.dev`) is
read as `type: ebsi`.

```yaml
credentials:
- type: CustomerCredential
trustedIssuersLists:
- type: ebsi
url: https://til-pdc.ebsi.fiware.dev
```

The issuer of a credential is looked up in those registries by its identifier,
whether that is a DID or an HTTPS URL — an issuer identified by an HTTPS URL is
trusted by being registered there, like any other. `url: "*"` in a trusted
issuers list waives the lookup for that credential type. See
[docs/https-issuer-identifiers.md](docs/https-issuer-identifiers.md) for how
the signing keys of an HTTPS-identified issuer are discovered and for
`verifier.httpsIssuerAllowedHosts`.

#### Templating

The login-page, provided at ```/api/v1/loginQR```, can be configured by providing a different template in the ```templateDir```. The templateDir needs to contain a file named ```verifier_present_qr.html``` which will be rendered on calls to the login-api. The template needs to include the QR-Code via ```<img src="data:{{.qrcode}}"```. Beside that, all options provided by the [goview-framework](https://github.com/foolin/goview) can be used. Static content(like icons, images) can be provided through the ```staticDir``` and will be available at the path ```/static```.
Expand Down
8 changes: 8 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ type Verifier struct {
// to accept a proof of any age. Defaults to
// DefaultLdProofMaxAgeSeconds.
LdProofMaxAge int `mapstructure:"ldProofMaxAge" default:"300"`
// Hosts — in `host` or `host:port` form — that the metadata of an
// HTTPS-based credential issuer may point to in addition to the issuer's
// own host. Everything the resolver fetches after the first well-known
// request (jwks_uri, authorization_servers) is named by a document the
// issuer serves, so by default it is confined to the issuer's own origin.
// List a host here when the authorization server or the JWKS of a trusted
// issuer genuinely lives elsewhere.
HttpsIssuerAllowedHosts []string `mapstructure:"httpsIssuerAllowedHosts"`
// RefreshToken groups all refresh token configuration.
RefreshToken RefreshToken `mapstructure:"refreshToken"`
}
Expand Down
16 changes: 12 additions & 4 deletions config/configClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,14 @@ type ClaimInclusion struct {
NewKey string `json:"newKey" mapstructure:"newKey"`
}

// TrustedParticipantsList represents a single trusted participants registry
// endpoint with an associated type. The Url is always the address of a
// registry to query — never the identity of an issuer. Issuers are looked up
// there by their identifier, which may be a DID or an HTTPS URL.
type TrustedParticipantsList struct {
// Type of praticipants list to be used - either gaia-x or ebsi
// Type of participants list to be used — "ebsi", "ebsi-v5", or "gaia-x".
Type string `json:"type" mapstructure:"type"`
// url of the list
// Url of the trusted participants registry endpoint.
Url string `json:"url" mapstructure:"url"`
}

Expand Down Expand Up @@ -283,11 +287,15 @@ func (t *TrustedParticipantsLists) UnmarshalJSON(data []byte) error {

// TrustedIssuersList represents a single trusted issuers registry endpoint
// with an associated type (e.g. "ebsi", "ebsi-v5"). Mirrors
// TrustedParticipantsList for issuers.
// TrustedParticipantsList for issuers. The Url is always the address of a
// registry to query — never the identity of an issuer. Issuers are looked up
// there by their identifier, which may be a DID or an HTTPS URL. The wildcard
// value "*" waives the lookup for the credential type it is configured for.
type TrustedIssuersList struct {
// Type of issuers list to be used — "ebsi" for v3/v4, "ebsi-v5" for v5.
Type string `json:"type" mapstructure:"type"`
// Url of the trusted issuers registry endpoint.
// Url of the trusted issuers registry endpoint, or "*" to accept any
// issuer for the credential type without a registry lookup.
Url string `json:"url" mapstructure:"url"`
}

Expand Down
47 changes: 47 additions & 0 deletions config/data/config_test_https_issuer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
server:
port: 3000
staticDir: "views/static"
templateDir: "views/"

logging:
level: "DEBUG"
jsonLogging: true
logRequests: true

verifier:
did: "did:key:somekey"
tirAddress: "https://test.dev/trusted_issuer/v3/issuers/"

m2m:
authEnabled: false

configRepo:
services:
- id: testServiceHttps
defaultOidcScope: httpsScope
oidcScopes:
httpsScope:
credentials:
- type: EmployeeCredential
# Trust-list entries are always registry endpoints — an issuer,
# whether identified by a DID or by an HTTPS URL, is looked up
# there. Both a v3/v4 and a v5 registry are configured here.
trustedParticipantsLists:
- type: ebsi
url: https://tir-pdc.ebsi.fiware.dev
- type: ebsi-v5
url: https://tir-v5.ebsi.fiware.dev
trustedIssuersLists:
- type: ebsi
url: https://til-pdc.ebsi.fiware.dev
- type: ebsi-v5
url: https://til-v5.ebsi.fiware.dev
presentationDefinition:
id: https-pd
input_descriptors:
- id: https-descriptor
constraints:
fields:
- id: https-field
path:
- $.vc.my.claim
32 changes: 32 additions & 0 deletions config/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,35 @@ func TestRefreshTokenConfigDefaults(t *testing.T) {
})
}
}

// TestReadConfigHttpsIssuer verifies parsing of a YAML config for a service
// whose credentials are verified against several typed registry endpoints,
// ensuring the per-entry type survives parsing.
func TestReadConfigHttpsIssuer(t *testing.T) {
config.Reset()
gotConfig, err := ReadConfig("data/config_test_https_issuer.yaml")
assert.NoError(t, err, "ReadConfig should not return an error for HTTPS issuer config")

services := gotConfig.ConfigRepo.Services
assert.Len(t, services, 1)
assert.Equal(t, "testServiceHttps", services[0].Id)

credentials := services[0].ServiceScopes["httpsScope"].Credentials
assert.Len(t, credentials, 1)
cred := credentials[0]
assert.Equal(t, "EmployeeCredential", cred.Type)

// Verify TrustedParticipantsLists: both registry entries with their types.
assert.Len(t, cred.TrustedParticipantsLists, 2, "Expected two trusted participant registry entries")
assert.Equal(t, "ebsi", cred.TrustedParticipantsLists[0].Type)
assert.Equal(t, "https://tir-pdc.ebsi.fiware.dev", cred.TrustedParticipantsLists[0].Url)
assert.Equal(t, "ebsi-v5", cred.TrustedParticipantsLists[1].Type)
assert.Equal(t, "https://tir-v5.ebsi.fiware.dev", cred.TrustedParticipantsLists[1].Url)

// Verify TrustedIssuersLists: both registry entries with their types.
assert.Len(t, cred.TrustedIssuersLists, 2, "Expected two trusted issuer registry entries")
assert.Equal(t, "ebsi", cred.TrustedIssuersLists[0].Type)
assert.Equal(t, "https://til-pdc.ebsi.fiware.dev", cred.TrustedIssuersLists[0].Url)
assert.Equal(t, "ebsi-v5", cred.TrustedIssuersLists[1].Type)
assert.Equal(t, "https://til-v5.ebsi.fiware.dev", cred.TrustedIssuersLists[1].Url)
}
Loading
Loading