Support for W3C VC Data Model 2.0 - #125
Conversation
wistefan
commented
Sep 22, 2026
- W3C VC Data Model 2.0 JSON-LD context registered it in the embedded document loader.
- added Version detection
- Enforced VC Data Model version filtering and reject versions not in the allowed list
- support for VC 2.0 status list types (BitstringStatusListEntry / BitstringStatusListCredential) alongside existing StatusList2021Entry
5-step plan covering: vendoring the V2 JSON-LD context, adding version detection and configuration, enforcing version filtering in validation, updating presentation marshaling, and end-to-end integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Clarify scope: V2 support is for incoming credentials only, not M2M - Replace Step 4 (M2M/marshaling) with BitstringStatusListEntry handling for V2 status lists, per reviewer directive - Change DetectVCDataModelVersion to return []string (slice) instead of single string to handle ambiguous both-contexts-present case correctly - Move version check to be first in ValidateVC (fail fast before date validation) - Remove validationMode "none" exception: version check applies regardless of validation mode - Add api/api.yaml to Step 5 for OpenAPI spec example updates - Clarify that M2M presentation context does not auto-propagate from credential context Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…-59/plan into ticket-59/work Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/6
… loader Add the official W3C Credentials V2 context (https://www.w3.org/ns/credentials/v2) as a vendored file so JSON-LD canonicalization of VC 2.0 credentials does not depend on network access. Register the new context in the embedded loader alongside the existing V1 and JWS-2020 contexts. Add test coverage for the V2 context serving. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implemented step 1 of 5: Vendor the W3C VC Data Model 2.0 JSON-LD context and register it in the embedded loader
- Remove VCVerifier binary from git tracking - Add .gitignore to prevent build artifacts from being committed - Add trailing newline to credentials-v2.jsonld for POSIX consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… JSON-LD context' (#7) from ticket-59/step-1 into ticket-59/work Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/7
…tion Introduce DetectVCDataModelVersion() in common/credential.go to identify which W3C VC Data Model version (1.1 or 2.0) a credential uses based on its @context array. Add VCDataModelVersions config field to control which versions the verifier accepts, with startup validation in verifyConfig. When unset, both versions are accepted by default. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Change VCDataModelVersionAll from a mutable package-level var to a
function returning a fresh copy, preventing accidental mutation of the
global source of truth
- Fix slice aliasing in verifyConfig: VCDataModelVersionAll() now returns
a copy, so the config slice no longer shares a backing array with the
package-level data
- Use literal []string{"1.1", "2.0"} in test expectations to make them
independent of the code under test
- Add mutation-safety test verifying that modifying the returned slice
does not affect subsequent VCDataModelVersionAll() calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t W3C Verifiable Credentials 2.0' (#8) from ticket-59/step-2 into ticket-59/work Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/8
Wire the version-detection logic into CredentialValidator so credentials whose @context does not include an allowed VC Data Model version are rejected before any other validation runs. - Add vcDataModelVersions field and ErrorVCDataModelVersionNotAccepted sentinel to CredentialValidator / jwt_verifier.go - Add hasOverlap helper for set intersection check - Place the version gate first in ValidateVC (before date checks) - Pass config.Verifier.VCDataModelVersions through from verifier.go - Add comprehensive table-driven tests covering accept/reject for V1, V2, both, unknown contexts, empty config bypass, ordering guarantee, and the hasOverlap helper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t W3C Verifiable Credentials 2.0' (#9) from ticket-59/step-3 into ticket-59/work Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/9 Reviewed-by: wistefan <wistefan@dev-env.local>
…21Entry handling Verify that both V1 (StatusList2021Entry/StatusList2021Credential) and V2 (BitstringStatusListEntry/BitstringStatusListCredential) status list types are correctly processed through the credential status validation service. New tests cover: - StatusList2021Entry revoked/clear bit detection (backward compatibility) - Mixed V1/V2 status entries in a single credential - Unknown entry types gracefully skipped without fetch - Unknown + known entries: unknown skipped, known still checked - isRecognisedStatusEntryType dispatch for V1, V2, and unknown types - isStatusListCredentialType recognition for both credential types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pes (BitstringStatusListEntry)' (#10) from ticket-59/step-4 into ticket-59/work Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/10 Reviewed-by: wistefan <wistefan@dev-env.local>
Add integration tests in verifier_test.go that exercise the full AuthenticationResponse pipeline with real CredentialValidator to verify vcDataModelVersions filtering works correctly for V1, V2, and mixed credential presentations. Update server.yaml with commented documentation of the vcDataModelVersions configuration option including allowed values and default behavior. Update api/api.yaml OpenAPI spec to document VC Data Model v2.0 support in VerifiablePresentation and VerifiableCredential schema descriptions and @context field documentation. CLAUDE.md updates are intentionally skipped per agent policy (cached system prompt); a plan-mode agent should update it separately. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix misleading comment in server.yaml: the example labeled "restrict to a single version" was showing both versions. Split into two examples — one showing both versions explicitly and one showing single-version restriction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…, documentation, and config example updates' (#11) from ticket-59/step-5 into ticket-59/work Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/11
|
Please apply one of the following labels to the PR: |
|
Please apply one of the following labels to the PR: |
vramperez
left a comment
There was a problem hiding this comment.
Nice work on the VC 2.0 support — one issue with how the version gate decides which credentials it applies to.
| // no data model version, so the vcDataModelVersions allowlist does not apply to | ||
| // it. A credential declaring an unknown @context still participates and is | ||
| // rejected by the version gate. | ||
| func isVersionedDataModelCredential(credential *common.Credential) bool { |
There was a problem hiding this comment.
The version gate is skipped whenever Context is empty, but the JWT/JSON-LD parsers drop a string-valued @context (and VC-JOSE-COSE payloads have no vc claim), so e.g. a v1.1 jwt_vc with "@context": "https://www.w3.org/2018/credentials/v1" passes a ["2.0"] allowlist. Consider exempting by format (Format() == common.FormatSDJWT) instead of by empty context, and parsing @context with toStringSlice in presentation_parser.go.
Mortega5
left a comment
There was a problem hiding this comment.
Findings outside the diff
- VC 2.0 over JOSE is not supported. VCDM 2.0 secures JWT credentials with VC-JOSE-COSE (
typ: vc+jwt/vp+jwt), where the JWT payload is the credential/presentation and there is novc/vpclaim.jwtClaimsToCredentialonly readsclaims["vc"], so such a credential ends up with empty issuer, types and subject. Avp+jwtpresentation is rejected outright withErrorPresentationNoCredentials, because both JWT VP parsers require thevpclaim (verifier/presentation_parser.go:232and:914).EnvelopedVerifiableCredential(VCDM 2.0 §4.13,data:application/vc+jwt,...inside a VP) is not handled either. In practice, "VC 2.0 support" today meansldp_vcsigned withJsonWebSignature2020, or ajwt_vc_jsonthat uses the v1.1-stylevcclaim with a v2 context. - Data Integrity. The securing mechanism native to VCDM 2.0 for JSON-LD is
DataIntegrityProof(ecdsa-rdfc-2019,eddsa-rdfc-2022, …), which is still parsed but not verified (see Known Gaps).ldp_vcissuers following VC 2.0 are likely to use it rather thanJsonWebSignature2020. It doesn't need to be solved in this PR, but it should be stated as a limitation.
Documentation
README.md: the configuration reference block documents everyverifieroption (ldProofMaxAge,httpsIssuerAllowedHosts,httpsIssuerAllowPrivateNetworks, …) but notvcDataModelVersions. Please add it there with the default, the allowed values, the note that values must be quoted, and the SD-JWT exemption. The intro also still links only to the VCDM v1 spec.- The supported scope of VC 2.0 (the
ldp_vc+JsonWebSignature2020path; no VC-JOSE-COSE, no enveloped credentials, no Data Integrity) isn't written down anywhere. A short section in the README or underdocs/would prevent the "Supports both v1.1 and v2.0" wording inapi/api.yamlfrom being read as full support.
| func DetectVCDataModelVersion(contexts []string) []string { | ||
| found := make(map[string]bool, len(contextToVersion)) | ||
| for _, ctx := range contexts { | ||
| if ver, ok := contextToVersion[ctx]; ok { | ||
| found[ver] = true | ||
| } | ||
| } | ||
|
|
||
| // Return in a stable order matching VCDataModelVersionAll(). | ||
| var result []string | ||
| for _, ver := range VCDataModelVersionAll() { | ||
| if found[ver] { | ||
| result = append(result, ver) | ||
| } | ||
| } | ||
| return result |
There was a problem hiding this comment.
Version detection matches the base context at any position in @context, but both data models pin it to the first entry. VCDM 2.0 §4.3 says the first item MUST be https://www.w3.org/ns/credentials/v2, and VCDM 1.1 says the same for https://www.w3.org/2018/credentials/v1. As a result:
["https://example.org/ctx", "https://www.w3.org/ns/credentials/v2"]is accepted as 2.0.["https://www.w3.org/2018/credentials/v1", "https://www.w3.org/ns/credentials/v2"]passes both a["1.1"]and a["2.0"]allowlist, although it is valid under neither. Forldp_vcit would probably fail later at canonicalization (protected term redefinition).jwt_vc_jsonhas no JSON-LD processing, though, so it goes through.
Suggest deriving the version from @context[0] only and rejecting documents that carry both base contexts.
| for _, v := range verifierConfig.VCDataModelVersions { | ||
| if !slices.Contains(common.VCDataModelVersionAll(), v) { //nolint:govet | ||
| return ErrorUnsupportedVCDataModelVersion | ||
| } | ||
| } |
There was a problem hiding this comment.
An unquoted YAML list such as vcDataModelVersions: [1.1, 2.0] is decoded as ["1.1", "2"]: YAML reads it as a float and WeaklyTypedInput stringifies it. Startup then fails with unsupported_vc_data_model_version, and nothing says which value was wrong. It would help to normalize "2" → "2.0" (and "1" → "1.1"?), or at least include the offending value in the error, e.g. fmt.Errorf("%w: %q", ErrorUnsupportedVCDataModelVersion, v).
| // Version gate: reject credentials whose VC Data Model version is not allowed. | ||
| // Only credentials that declare a @context participate in the W3C data model | ||
| // versioning; SD-JWT VCs carry none and are validated through their `vct` type. | ||
| if len(cv.vcDataModelVersions) > 0 && isVersionedDataModelCredential(verifiableCredential) { |
There was a problem hiding this comment.
The gate only covers the credentials; the presentation's own @context is never checked. So with ["2.0"], a VCDM 1.1 presentation still gets through as long as its credentials are 2.0. If that is intentional ("incoming credentials" only), please state it in the config docs. Otherwise, should the VP context be gated as well?
The credential parsers type-asserted `@context` and `type` to []interface{}
only, so a valid JSON-LD document spelling them as a single string was parsed
with an empty context. The version gate exempted credentials without a
@context (meant to carve out SD-JWT VCs), so such a credential bypassed the
allowlist entirely: a v1.1 credential with a string-valued @context passed a
["2.0"] configuration.
Both halves are fixed:
- Both parsers now use common.ToStringSlice (already present, unexported, and
unused on these paths) for `@context` and `type`, accepting strings as well
as arrays.
- The exemption is keyed on the credential format instead of on the absence of
a @context. Only SD-JWT VCs are exempt; an ldp_vc/jwt_vc without a
recognized base context is now rejected rather than skipped, so the gate
cannot be bypassed by omitting or mangling the context.
The rejection warning now names the credential and the @context it saw, since
for the newly rejected credentials the detected-version list is empty and says
nothing on its own.
BREAKING: deployments accepting jwt_vc/ldp_vc credentials with an absent,
string-valued or non-W3C @context will now see vc_data_model_version_not_accepted.
Version detection matched a base context at any position in @context, but both data models pin it to the first entry (VCDM 2.0 §4.3, VCDM 1.1 §4.1). Two consequences: - ["https://example.org/ctx", ".../ns/credentials/v2"] was detected as 2.0, letting an arbitrary document claim a version by appending the base context after its own. - A document carrying both base contexts was detected as 1.1 AND 2.0, so it satisfied a ["1.1"] as well as a ["2.0"] allowlist although it is valid under neither. For ldp_vc this would likely fail later at canonicalization, but jwt_vc_json does no JSON-LD processing and went through. DetectVCDataModelVersion now reads the first entry only and returns no version when a conflicting base context follows it. A repetition of the same base context is redundant but unambiguous and stays accepted. The result stays a slice so the hasOverlap call site is unchanged.
An unquoted YAML list such as `vcDataModelVersions: [1.1, 2.0]` is decoded as ["1.1", "2"]: YAML reads the entries as floats, and stringifying "2.0" drops the fractional zero while "1.1" survives by accident of its decimal representation. Startup then aborted with a bare `unsupported_vc_data_model_version` naming neither the field nor the value, for a configuration that looks correct to whoever wrote it. common.NormalizeVCDataModelVersion now maps every accepted spelling onto its canonical identifier, and verifyConfig normalizes the configured list in place so the aliasing does not have to be repeated per request. Unrecognized values are still rejected, but the error now names the offending value and the supported ones. A config fixture pins the YAML decoding behaviour itself, so the reason for the normalization does not have to be rediscovered.
The configuration reference in README.md documented every other verifier option but not vcDataModelVersions, and the supported scope of VC 2.0 was not written down anywhere, leaving the "Supports both v1.1 and v2.0" wording in api/api.yaml to be read as full support. - README: vcDataModelVersions in the configuration reference (default, allowed values, why the values must be quoted, first-@context-entry rule, SD-JWT exemption, credentials-only scope); the intro now links both data models. - docs/vc-data-model-versions.md: version detection, the option, status list types, and an explicit table of what VC 2.0 support does and does not cover. - api/api.yaml: the descriptions now name the securing mechanisms that are actually verified, and state that the first @context entry decides the version. - server.yaml / config.go: the same scope, plus the quoting note. - CLAUDE.md: VC-JOSE-COSE and enveloped credentials added to Known Gaps, and a section on the version handling. Unsupported, and now stated as such: VC-JOSE-COSE (typ vc+jwt / vp+jwt), EnvelopedVerifiableCredential (VCDM 2.0 §4.13), and DataIntegrityProof cryptosuites.
Summarizes the topic and flags the strict version gate as a breaking change: ldp_vc/jwt_vc credentials without a recognized base context in the first @context position are now rejected, and there is no configuration that accepts them. Also records the VC 2.0 scope and its limitations.
VerifyJWTAndReturnKey resolves the signing key from the `kid` header but the
asserted identity — a credential's issuer, a presentation's holder — is read
from the `iss` claim, and nothing required the two to name the same DID.
A credential signed with a self-generated key while claiming an unrelated
issuer therefore verified, and was parsed carrying the claimed issuer:
kid: did:jwk:<attacker key>#0
iss: did:web:trusted.issuer.example.com
=> ACCEPTED, credential.issuer = did:web:trusted.issuer.example.com
Nothing downstream re-checks who actually signed the credential, and the
trusted-issuer and trusted-participant lookups key off Contents().Issuer.ID,
so a credential could be attributed to any issuer a deployment trusts. The
did:elsi branch guarded against exactly this mismatch; the general DID path
did not.
A kid naming a DID other than the iss claim is now rejected with
issuer_key_mismatch. A kid that is not a DID (a bare key id, a relative
fragment) asserts no identity of its own and is not compared, so iss alone
keeps deciding. This covers every JWT path, SD-JWT VCs included; the JSON-LD
path already bound the proof key to the credential issuer.
The elsi guard now only has to catch a did:elsi kid with no iss claim, since
the general check runs first — the two mismatch cases in its test now surface
as issuer_key_mismatch, and both still reject.
Both topics are noted and will be fixed in additional PRs. |
vramperez
left a comment
There was a problem hiding this comment.
Thanks for addressing the previous comments — the fixes look good. One remaining edge case in the version detection.
| if len(contexts) == 0 { | ||
| return nil | ||
| } | ||
| version, isBaseContext := contextToVersion[contexts[0]] |
There was a problem hiding this comment.
ToStringSlice drops non-string entries, so @context[0] here is the first string rather than the document's first entry: [{"@vocab": "..."}, "https://www.w3.org/ns/credentials/v2"] is still detected as 2.0. Consider checking the first raw @context item before normalization (e.g. reject when it is not a string).