Skip to content

Support for W3C VC Data Model 2.0 - #125

Merged
wistefan merged 26 commits into
mainfrom
ticket-59/work
Sep 24, 2026
Merged

wistefan merged 26 commits into
mainfrom
ticket-59/work

Conversation

@wistefan

Copy link
Copy Markdown
Collaborator
  • 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

general-agent-3 and others added 18 commits September 18, 2026 11:59
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
@github-actions

Copy link
Copy Markdown

Please apply one of the following labels to the PR: patch, minor, or major. See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown

Please apply one of the following labels to the PR: patch, minor, or major. See CONTRIBUTING.md for details.

@wistefan wistefan added the minor Should be applied for new functionality or bigger updates. label Sep 22, 2026
@wistefan
wistefan requested a review from Mortega5 September 22, 2026 12:14

@vramperez vramperez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on the VC 2.0 support — one issue with how the version gate decides which credentials it applies to.

Comment thread verifier/jwt_verifier.go
// 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 4e45bbf

@Mortega5 Mortega5 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 no vc/vp claim. jwtClaimsToCredential only reads claims["vc"], so such a credential ends up with empty issuer, types and subject. A vp+jwt presentation is rejected outright with ErrorPresentationNoCredentials, because both JWT VP parsers require the vp claim (verifier/presentation_parser.go:232 and :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 means ldp_vc signed with JsonWebSignature2020, or a jwt_vc_json that uses the v1.1-style vc claim 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_vc issuers following VC 2.0 are likely to use it rather than JsonWebSignature2020. 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 every verifier option (ldProofMaxAge, httpsIssuerAllowedHosts, httpsIssuerAllowPrivateNetworks, …) but not vcDataModelVersions. 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 + JsonWebSignature2020 path; no VC-JOSE-COSE, no enveloped credentials, no Data Integrity) isn't written down anywhere. A short section in the README or under docs/ would prevent the "Supports both v1.1 and v2.0" wording in api/api.yaml from being read as full support.

Comment thread common/credential.go Outdated
Comment on lines +453 to +468
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. For ldp_vc it would probably fail later at canonicalization (protected term redefinition). jwt_vc_json has 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 5797e50

Comment thread verifier/verifier.go Outdated
Comment on lines +1916 to +1920
for _, v := range verifierConfig.VCDataModelVersions {
if !slices.Contains(common.VCDataModelVersionAll(), v) { //nolint:govet
return ErrorUnsupportedVCDataModelVersion
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 0aad47a

Comment thread verifier/jwt_verifier.go
// 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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in b8d3b45

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.
@wistefan

Copy link
Copy Markdown
Collaborator Author
  • 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 no vc/vp claim. jwtClaimsToCredential only reads claims["vc"], so such a credential ends up with empty issuer, types and subject. A vp+jwt presentation is rejected outright with ErrorPresentationNoCredentials, because both JWT VP parsers require the vp claim (verifier/presentation_parser.go:232 and :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 means ldp_vc signed with JsonWebSignature2020, or a jwt_vc_json that uses the v1.1-style vc claim 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_vc issuers following VC 2.0 are likely to use it rather than JsonWebSignature2020. It doesn't need to be solved in this PR, but it should be stated as a limitation.

Both topics are noted and will be fixed in additional PRs.

@vramperez vramperez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the previous comments — the fixes look good. One remaining edge case in the version detection.

Comment thread common/credential.go
if len(contexts) == 0 {
return nil
}
version, isBaseContext := contextToVersion[contexts[0]]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@wistefan
wistefan merged commit 461131e into main Sep 24, 2026
17 checks passed
@wistefan
wistefan deleted the ticket-59/work branch September 24, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Should be applied for new functionality or bigger updates.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants