Skip to content

Proof suites

Each proof format is one suite behind the same key/verify primitives.

VC-JWT

openvc.proof.vc_jwt

openvc.proof.vc_jwt — VC-JWT proof suite (ES256 / EdDSA).

Responsibilities

  • peek_issuer : read iss + kid from a token WITHOUT verifying — so the caller knows which DID/key to resolve. The result is UNTRUSTED.
  • verify : verify the JWS signature against a resolved public JWK, validate temporal claims, and reconcile the JWT envelope with the embedded vc object per the W3C VC-JWT rules. Returns the credential.
  • sign : assemble a compact JWS by delegating the raw signature to a SigningKey backend (which may be backed by an HSM / Vault), so a private key never has to live in this process.

Security posture

  • Algorithm allow-list is fixed (ES256, ES384, EdDSA, and the RFC 9864 fully-specified name Ed25519, which is EdDSA over Ed25519). alg: none, RS, HS are rejected before any crypto runs — this is the primary defence against alg-confusion. (ES384 is the P-384 leg of the Data Integrity ecdsa-*-2019 suites; it does not change the EBSI/EUDI-preferred ES256 path. IANA deprecated the polymorphic "EdDSA" in RFC 9864, so "Ed25519" is accepted alongside it — see the versioning guide for the migration.)
  • The verification algorithm is taken from the token header ONLY after checking it against the allow-list, and is then pinned when calling the verifier.
  • peek_issuer never influences verification; it exists solely to select a key.

ClaimsInvalid

Bases: ProofError

A required claim is missing, malformed, or does not satisfy policy.

Source code in src/openvc/proof/errors.py
47
48
class ClaimsInvalid(ProofError):
    """A required claim is missing, malformed, or does not satisfy policy."""

MalformedToken

Bases: ProofError

A compact JWS / SD-JWT string is not well-formed.

Source code in src/openvc/proof/errors.py
43
44
class MalformedToken(ProofError):
    """A compact JWS / SD-JWT string is not well-formed."""

ProofError

Bases: OpenvcError

Base class for every proof-suite failure (signature, format, temporal, policy).

Source code in src/openvc/proof/errors.py
23
24
class ProofError(OpenvcError):
    """Base class for every proof-suite failure (signature, format, temporal, policy)."""

SignatureInvalid

Bases: ProofError

A proof/signature did not verify.

Source code in src/openvc/proof/errors.py
27
28
class SignatureInvalid(ProofError):
    """A proof/signature did not verify."""

UnsupportedAlgorithm

Bases: ProofError

The JOSE algorithm is not in the {ES256, ES384, EdDSA, Ed25519} allow-list.

Source code in src/openvc/proof/errors.py
39
40
class UnsupportedAlgorithm(ProofError):
    """The JOSE algorithm is not in the ``{ES256, ES384, EdDSA, Ed25519}`` allow-list."""

SigningKey

Bases: Protocol

A private-key handle. sign may call out to an HSM/Vault.

sign MUST return a JWS-compatible signature: * ES256 -> raw R||S concatenation, 64 bytes (NOT DER) * ES384 -> raw R||S concatenation, 96 bytes (NOT DER) * EdDSA -> raw 64-byte Ed25519 signature

Source code in src/openvc/proof/vc_jwt.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@runtime_checkable
class SigningKey(Protocol):
    """A private-key handle. `sign` may call out to an HSM/Vault.

    `sign` MUST return a JWS-compatible signature:
      * ES256  -> raw R||S concatenation, 64 bytes (NOT DER)
      * ES384  -> raw R||S concatenation, 96 bytes (NOT DER)
      * EdDSA  -> raw 64-byte Ed25519 signature
    """
    @property
    def alg(self) -> str:
        """The JOSE algorithm identifier — ``"ES256"``, ``"ES384"``, ``"EdDSA"`` or
        the RFC 9864 fully-specified ``"Ed25519"``."""
    @property
    def kid(self) -> str:
        """The verification-method id this key signs as (e.g. ``did:…#key-1``)."""
    def sign(self, signing_input: bytes) -> bytes:
        """Sign *signing_input*; return the raw JWS signature (R‖S / 64-byte, never DER)."""

alg property

The JOSE algorithm identifier — "ES256", "ES384", "EdDSA" or the RFC 9864 fully-specified "Ed25519".

kid property

The verification-method id this key signs as (e.g. did:…#key-1).

sign(signing_input)

Sign signing_input; return the raw JWS signature (R‖S / 64-byte, never DER).

Source code in src/openvc/proof/vc_jwt.py
91
92
def sign(self, signing_input: bytes) -> bytes:
    """Sign *signing_input*; return the raw JWS signature (R‖S / 64-byte, never DER)."""

VcJwtProofSuite

VC-JWT (JOSE-secured Verifiable Credential) proof suite.

Source code in src/openvc/proof/vc_jwt.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
class VcJwtProofSuite:
    """VC-JWT (JOSE-secured Verifiable Credential) proof suite."""

    def __init__(self, *, leeway_s: int = DEFAULT_LEEWAY_S, allow_pq: bool = False) -> None:
        self._leeway = leeway_s
        # allow_pq merges the EXPERIMENTAL ML-DSA algs into this suite's allow-list; the
        # default suite rejects ML-DSA at the allow-list, before any crypto (ADR-0004).
        self._algs = ALLOWED_ALGS | ALLOWED_ALGS_PQ if allow_pq else ALLOWED_ALGS

    # -- untrusted inspection --------------------------------------------- #

    def peek_issuer(self, token: str) -> tuple[str, str | None]:
        """Return (iss, kid) WITHOUT verifying the signature.

        UNTRUSTED. Use only to decide which DID to resolve and which key to fetch.
        Never make a trust decision on this output.
        """
        header_b64, payload_b64, _ = _split(token)
        try:
            header = json.loads(_b64url_decode(header_b64))
            payload = json.loads(_b64url_decode(payload_b64))
        except (ValueError, json.JSONDecodeError, RecursionError) as exc:
            raise MalformedToken("header/payload is not valid base64url JSON") from exc
        if not isinstance(header, dict) or not isinstance(payload, dict):
            # Valid JSON but not an object (e.g. `[0]`) must fail closed as a typed
            # MalformedToken, not a bare AttributeError that escapes OpenvcError.
            raise MalformedToken("JWS header and payload must be JSON objects")

        iss = payload.get("iss")
        if not iss:
            vc = payload.get("vc")
            iss = vc.get("issuer") if isinstance(vc, dict) else None
        if isinstance(iss, dict):          # issuer can be an object {"id": ...}
            iss = iss.get("id")
        if not iss or not isinstance(iss, str):
            raise MalformedToken("no issuer (iss / vc.issuer) present")
        return iss, header.get("kid")

    def peek_claims(self, token: str) -> dict[str, Any]:
        """Decode the full claim set WITHOUT verifying the signature. UNTRUSTED.
        Used to read TIR accreditation bodies before the trust-chain walk."""
        _, payload_b64, _ = _split(token)
        try:
            payload = json.loads(_b64url_decode(payload_b64))
        except (ValueError, json.JSONDecodeError, RecursionError) as exc:
            raise MalformedToken("payload is not valid base64url JSON") from exc
        if not isinstance(payload, dict):
            raise MalformedToken("JWS payload must be a JSON object")
        return payload

    # -- verification ------------------------------------------------------ #

    def verify(
        self,
        token: str,
        *,
        public_key_jwk: dict[str, Any],
        expected_types: list[str] | None = None,
        audience: str | None = None,
    ) -> VerifiedCredential:
        """Verify signature + temporal claims + VC-JWT reconciliation."""
        header_b64, _, _ = _split(token)
        try:
            header = json.loads(_b64url_decode(header_b64))
        except (ValueError, json.JSONDecodeError, RecursionError) as exc:
            raise MalformedToken("invalid JWS header") from exc
        if not isinstance(header, dict):
            raise MalformedToken("JWS header must be a JSON object")

        alg = header.get("alg")
        if alg not in self._algs:                          # allow-list BEFORE crypto
            raise UnsupportedAlgorithm(f"algorithm {alg!r} is not permitted")
        reject_unknown_crit(header)

        if alg in ALLOWED_ALGS_PQ:
            # ML-DSA is not a PyJWT algorithm — verify the signature through the
            # dependency-light primitive and validate the JWT claims ourselves.
            claims = self._verify_mldsa(token, alg, public_key_jwk, audience)
        else:
            key = self._jwk_to_key(public_key_jwk, alg)
            try:
                claims = _JWT.decode(                      # PyJWT instance that knows "Ed25519"
                    token,
                    key=key,
                    algorithms=[alg],                     # pinned, single alg
                    leeway=self._leeway,
                    audience=audience,
                    options={
                        "require": ["iss"],
                        "verify_signature": True,
                        "verify_exp": True,
                        "verify_nbf": True,
                        "verify_aud": audience is not None,
                    },
                )
            except pyjwt.InvalidSignatureError as exc:
                raise SignatureInvalid(str(exc)) from exc
            except (pyjwt.PyJWTError, OverflowError) as exc:  # +Inf exp -> OverflowError in PyJWT
                raise ClaimsInvalid(str(exc)) from exc

        credential = claims.get("vc")
        if not isinstance(credential, dict):
            raise ClaimsInvalid("no embedded `vc` object in token")

        # Defence in depth: also honour the credential body's own validity window
        # (VCDM 2.0 validFrom/validUntil, VCDM 1.1 issuanceDate/expirationDate). The JWT
        # nbf/exp checked above is the primary temporal gate, but an issuer — EBSI's
        # VCDM 2.0 envelopes among them — may encode expiry ONLY in the credential body;
        # without this an expired such credential would still verify. Same leeway; there
        # is no Data-Integrity proof object on the JOSE path, so pass an empty one.
        check_validity_window(credential, {}, now=None, leeway_s=self._leeway)

        issuer, subject = self._reconcile(claims, credential)
        if expected_types:
            self._check_types(credential, expected_types)

        return VerifiedCredential(
            credential=credential, issuer=issuer, subject=subject, claims=claims,
        )

    # -- signing (issuance) ----------------------------------------------- #

    def sign(
        self,
        credential: dict[str, Any],
        *,
        signing_key: SigningKey,
        expires_in_s: int | None = None,
    ) -> str:
        """Wrap a VCDM credential in a VC-JWT, signing via the key backend.

        The raw signature is produced by `signing_key.sign(...)`, so an HSM/Vault
        backend keeps the private key out of this process entirely. The algorithm
        is allow-listed by the shared compact-JWS assembler before signing.
        """
        now = int(time.time())
        issuer = credential.get("issuer")
        issuer = issuer.get("id") if isinstance(issuer, dict) else issuer
        cs = credential.get("credentialSubject")
        subject = cs.get("id") if isinstance(cs, dict) else None   # may be a list of subjects

        payload: dict[str, Any] = {
            "iss": issuer,
            "nbf": now,
            "iat": now,
            "vc": credential,
        }
        if credential.get("id") is not None:      # a null jti fails RFC 7519 (must be a string)
            payload["jti"] = credential["id"]
        if subject:
            payload["sub"] = subject
        if expires_in_s is not None:
            payload["exp"] = now + expires_in_s

        header = {"alg": signing_key.alg, "typ": "JWT", "kid": signing_key.kid}

        from ._jws import sign_compact          # local import breaks the _jws<->vc_jwt cycle
        return sign_compact(header, payload, signing_key=signing_key, allowed_algs=self._algs)

    # -- internals --------------------------------------------------------- #

    def _verify_mldsa(
        self, token: str, alg: str, public_key_jwk: dict[str, Any], audience: str | None,
    ) -> dict[str, Any]:
        """Verify an ML-DSA VC-JWT: signature via the dependency-light primitive (PyJWT
        has no ML-DSA), then the JWT claims validated here."""
        from ..keys import KeyBackendError, verify_signature
        try:
            header_b64, payload_b64, sig_b64 = token.split(".")
        except ValueError as exc:
            raise MalformedToken("not a compact JWS (need three parts)") from exc
        signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
        try:
            signature = _b64url_decode(sig_b64)
            payload = _b64url_decode(payload_b64)
        except (ValueError, TypeError) as exc:
            raise MalformedToken("token is not valid base64url") from exc
        try:
            ok = verify_signature(alg=alg, public_jwk=public_key_jwk,
                                  signing_input=signing_input, signature=signature)
        except KeyBackendError as exc:                 # malformed/mismatched AKP JWK, or
            raise SignatureInvalid(                    # ML-DSA unavailable -> typed ProofError
                f"could not verify {alg} signature: {exc}") from exc
        if not ok:
            raise SignatureInvalid(f"{alg} signature does not verify")
        try:
            claims = json.loads(payload)
        except (ValueError, json.JSONDecodeError, RecursionError) as exc:
            raise MalformedToken("payload is not valid JSON") from exc
        if not isinstance(claims, dict):
            raise ClaimsInvalid("JWT payload must be a JSON object")
        self._check_jwt_claims(claims, audience)
        return claims

    def _check_jwt_claims(self, claims: dict[str, Any], audience: str | None) -> None:
        # Mirror the PyJWT gate for the ML-DSA path: require iss, enforce exp/nbf with the
        # suite leeway (shared non-finite-safe helper), check aud when expected.
        if not isinstance(claims.get("iss"), str):
            raise ClaimsInvalid("the 'iss' claim is required")
        check_jwt_temporal(claims, leeway_s=self._leeway, subject="token")
        if audience is not None:
            aud = claims.get("aud")
            auds = aud if isinstance(aud, list) else [aud]
            if audience not in auds:
                raise ClaimsInvalid("audience mismatch")

    @staticmethod
    def _jwk_to_key(jwk: dict[str, Any], alg: str) -> Any:
        # openvc's OKP support is Ed25519-only: pin the curve BEFORE loading so
        # neither the RFC 9864 fully-specified "Ed25519" name (whose whole point is
        # that the curve is fixed) nor the polymorphic "EdDSA" can verify against a
        # non-Ed25519 OKP key (e.g. an Ed448 verificationMethod). Without this, PyJWT's
        # OKPAlgorithm would accept any OKP curve — disagreeing with the curve-pinned
        # openvc.keys.verify_signature used by the SD-JWT / VP / status paths.
        if alg in ("EdDSA", "Ed25519") and (
                jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519"):
            raise ProofError(f"{alg} requires an OKP Ed25519 key, got "
                             f"kty={jwk.get('kty')!r} crv={jwk.get('crv')!r}")
        # Pin the EC curve to the alg too, so ES256 cannot verify against a P-384 key (PyJWT's
        # ECAlgorithm would load whatever curve the JWK declares and hash by the alg) — matching
        # the curve-pinned openvc.keys.verify_signature used on the SD-JWT / VP / status paths.
        _EC_CRV = {"ES256": "P-256", "ES384": "P-384"}
        if alg in _EC_CRV and (jwk.get("kty") != "EC" or jwk.get("crv") != _EC_CRV[alg]):
            raise ProofError(f"{alg} requires an EC {_EC_CRV[alg]} key, got "
                             f"kty={jwk.get('kty')!r} crv={jwk.get('crv')!r}")
        try:
            if alg in ("ES256", "ES384"):
                return ECAlgorithm.from_jwk(json.dumps(jwk))
            return OKPAlgorithm.from_jwk(json.dumps(jwk))   # EdDSA / Ed25519 (Ed25519 only)
        except Exception as exc:
            raise ProofError(f"could not load {alg} key from JWK: {exc}") from exc

    @staticmethod
    def _reconcile(claims: dict[str, Any], vc: dict[str, Any]) -> tuple[str, str | None]:
        """Enforce W3C VC-JWT envelope/credential consistency (defence in depth)."""
        iss = claims.get("iss")
        if not isinstance(iss, str):        # decode() required "iss"; be explicit
            raise ClaimsInvalid("iss claim is missing or not a string")
        vc_issuer = vc.get("issuer")
        vc_issuer = vc_issuer.get("id") if isinstance(vc_issuer, dict) else vc_issuer
        if vc_issuer and vc_issuer != iss:
            raise ClaimsInvalid(f"iss {iss!r} != vc.issuer {vc_issuer!r}")

        sub = claims.get("sub")
        cs = vc.get("credentialSubject")
        vc_sub = cs.get("id") if isinstance(cs, dict) else None   # may be a list of subjects
        if sub and vc_sub and sub != vc_sub:
            raise ClaimsInvalid(f"sub {sub!r} != credentialSubject.id {vc_sub!r}")

        jti = claims.get("jti")
        if jti and vc.get("id") and jti != vc["id"]:
            raise ClaimsInvalid(f"jti {jti!r} != vc.id {vc['id']!r}")

        subject = sub if isinstance(sub, str) else (vc_sub if isinstance(vc_sub, str) else None)
        return iss, subject

    @staticmethod
    def _check_types(vc: dict[str, Any], expected: list[str]) -> None:
        types = vc.get("type", [])
        if isinstance(types, str):
            types = [types]
        missing = [t for t in expected if t not in types]
        if missing:
            raise ClaimsInvalid(f"credential missing required type(s): {missing}")

peek_issuer(token)

Return (iss, kid) WITHOUT verifying the signature.

UNTRUSTED. Use only to decide which DID to resolve and which key to fetch. Never make a trust decision on this output.

Source code in src/openvc/proof/vc_jwt.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def peek_issuer(self, token: str) -> tuple[str, str | None]:
    """Return (iss, kid) WITHOUT verifying the signature.

    UNTRUSTED. Use only to decide which DID to resolve and which key to fetch.
    Never make a trust decision on this output.
    """
    header_b64, payload_b64, _ = _split(token)
    try:
        header = json.loads(_b64url_decode(header_b64))
        payload = json.loads(_b64url_decode(payload_b64))
    except (ValueError, json.JSONDecodeError, RecursionError) as exc:
        raise MalformedToken("header/payload is not valid base64url JSON") from exc
    if not isinstance(header, dict) or not isinstance(payload, dict):
        # Valid JSON but not an object (e.g. `[0]`) must fail closed as a typed
        # MalformedToken, not a bare AttributeError that escapes OpenvcError.
        raise MalformedToken("JWS header and payload must be JSON objects")

    iss = payload.get("iss")
    if not iss:
        vc = payload.get("vc")
        iss = vc.get("issuer") if isinstance(vc, dict) else None
    if isinstance(iss, dict):          # issuer can be an object {"id": ...}
        iss = iss.get("id")
    if not iss or not isinstance(iss, str):
        raise MalformedToken("no issuer (iss / vc.issuer) present")
    return iss, header.get("kid")

peek_claims(token)

Decode the full claim set WITHOUT verifying the signature. UNTRUSTED. Used to read TIR accreditation bodies before the trust-chain walk.

Source code in src/openvc/proof/vc_jwt.py
166
167
168
169
170
171
172
173
174
175
176
def peek_claims(self, token: str) -> dict[str, Any]:
    """Decode the full claim set WITHOUT verifying the signature. UNTRUSTED.
    Used to read TIR accreditation bodies before the trust-chain walk."""
    _, payload_b64, _ = _split(token)
    try:
        payload = json.loads(_b64url_decode(payload_b64))
    except (ValueError, json.JSONDecodeError, RecursionError) as exc:
        raise MalformedToken("payload is not valid base64url JSON") from exc
    if not isinstance(payload, dict):
        raise MalformedToken("JWS payload must be a JSON object")
    return payload

verify(token, *, public_key_jwk, expected_types=None, audience=None)

Verify signature + temporal claims + VC-JWT reconciliation.

Source code in src/openvc/proof/vc_jwt.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def verify(
    self,
    token: str,
    *,
    public_key_jwk: dict[str, Any],
    expected_types: list[str] | None = None,
    audience: str | None = None,
) -> VerifiedCredential:
    """Verify signature + temporal claims + VC-JWT reconciliation."""
    header_b64, _, _ = _split(token)
    try:
        header = json.loads(_b64url_decode(header_b64))
    except (ValueError, json.JSONDecodeError, RecursionError) as exc:
        raise MalformedToken("invalid JWS header") from exc
    if not isinstance(header, dict):
        raise MalformedToken("JWS header must be a JSON object")

    alg = header.get("alg")
    if alg not in self._algs:                          # allow-list BEFORE crypto
        raise UnsupportedAlgorithm(f"algorithm {alg!r} is not permitted")
    reject_unknown_crit(header)

    if alg in ALLOWED_ALGS_PQ:
        # ML-DSA is not a PyJWT algorithm — verify the signature through the
        # dependency-light primitive and validate the JWT claims ourselves.
        claims = self._verify_mldsa(token, alg, public_key_jwk, audience)
    else:
        key = self._jwk_to_key(public_key_jwk, alg)
        try:
            claims = _JWT.decode(                      # PyJWT instance that knows "Ed25519"
                token,
                key=key,
                algorithms=[alg],                     # pinned, single alg
                leeway=self._leeway,
                audience=audience,
                options={
                    "require": ["iss"],
                    "verify_signature": True,
                    "verify_exp": True,
                    "verify_nbf": True,
                    "verify_aud": audience is not None,
                },
            )
        except pyjwt.InvalidSignatureError as exc:
            raise SignatureInvalid(str(exc)) from exc
        except (pyjwt.PyJWTError, OverflowError) as exc:  # +Inf exp -> OverflowError in PyJWT
            raise ClaimsInvalid(str(exc)) from exc

    credential = claims.get("vc")
    if not isinstance(credential, dict):
        raise ClaimsInvalid("no embedded `vc` object in token")

    # Defence in depth: also honour the credential body's own validity window
    # (VCDM 2.0 validFrom/validUntil, VCDM 1.1 issuanceDate/expirationDate). The JWT
    # nbf/exp checked above is the primary temporal gate, but an issuer — EBSI's
    # VCDM 2.0 envelopes among them — may encode expiry ONLY in the credential body;
    # without this an expired such credential would still verify. Same leeway; there
    # is no Data-Integrity proof object on the JOSE path, so pass an empty one.
    check_validity_window(credential, {}, now=None, leeway_s=self._leeway)

    issuer, subject = self._reconcile(claims, credential)
    if expected_types:
        self._check_types(credential, expected_types)

    return VerifiedCredential(
        credential=credential, issuer=issuer, subject=subject, claims=claims,
    )

sign(credential, *, signing_key, expires_in_s=None)

Wrap a VCDM credential in a VC-JWT, signing via the key backend.

The raw signature is produced by signing_key.sign(...), so an HSM/Vault backend keeps the private key out of this process entirely. The algorithm is allow-listed by the shared compact-JWS assembler before signing.

Source code in src/openvc/proof/vc_jwt.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def sign(
    self,
    credential: dict[str, Any],
    *,
    signing_key: SigningKey,
    expires_in_s: int | None = None,
) -> str:
    """Wrap a VCDM credential in a VC-JWT, signing via the key backend.

    The raw signature is produced by `signing_key.sign(...)`, so an HSM/Vault
    backend keeps the private key out of this process entirely. The algorithm
    is allow-listed by the shared compact-JWS assembler before signing.
    """
    now = int(time.time())
    issuer = credential.get("issuer")
    issuer = issuer.get("id") if isinstance(issuer, dict) else issuer
    cs = credential.get("credentialSubject")
    subject = cs.get("id") if isinstance(cs, dict) else None   # may be a list of subjects

    payload: dict[str, Any] = {
        "iss": issuer,
        "nbf": now,
        "iat": now,
        "vc": credential,
    }
    if credential.get("id") is not None:      # a null jti fails RFC 7519 (must be a string)
        payload["jti"] = credential["id"]
    if subject:
        payload["sub"] = subject
    if expires_in_s is not None:
        payload["exp"] = now + expires_in_s

    header = {"alg": signing_key.alg, "typ": "JWT", "kid": signing_key.kid}

    from ._jws import sign_compact          # local import breaks the _jws<->vc_jwt cycle
    return sign_compact(header, payload, signing_key=signing_key, allowed_algs=self._algs)

SD-JWT VC

openvc.proof.sd_jwt

openvc.proof.sd_jwt — SD-JWT VC proof suite (selective disclosure).

Implements the IETF SD-JWT VC family
  • RFC 9901 (the SD-JWT mechanism; formerly draft-ietf-oauth-selective-disclosure-jwt)
  • draft-ietf-oauth-sd-jwt-vc (the VC profile: vct, cnf, status …; a draft that builds on RFC 9901)

The third proof profile alongside VC-JWT (:mod:openvc.proof.vc_jwt) and Data Integrity — and the format EUDI/ARF converges on. It reuses the same JOSE machinery: the SigningKey backend (HSM-friendly, the private key never enters the process) and the fixed {ES256, ES384, EdDSA, Ed25519} algorithm allow-list checked before any crypto runs.

How SD-JWT works, briefly

Selectively-disclosable claims are removed from the issuer-signed JWT and each is replaced by the digest of a disclosure — a salted [salt, name, value] (object) or [salt, value] (array element) blob. The digests live under _sd (objects) or as {"...": digest} (array elements). The combined presentation is

<issuer-signed-jwt>~<disclosure 1>~...~<disclosure N>~<optional KB-JWT>

The holder chooses which disclosures to send; the verifier hashes each received disclosure, matches it against the digests, and reconstructs only the disclosed claims. A Key Binding JWT (typ: kb+jwt), signed by the holder key named in the issuer's cnf, binds a presentation to an audience + nonce and to the exact set of disclosures (via sd_hash).

Revocation is out of band: an SD-JWT VC's status claim can carry an IETF Token Status List reference — check it with :func:openvc.status.check_token_status over the verified claims.

SdJwtError

Bases: ProofError

Malformed SD-JWT structure, disclosure, or key-binding.

Source code in src/openvc/proof/sd_jwt.py
69
70
class SdJwtError(ProofError):
    """Malformed SD-JWT structure, disclosure, or key-binding."""

SdJwtVcProofSuite

SD-JWT VC issuance, holder presentation, and verification.

Source code in src/openvc/proof/sd_jwt.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
class SdJwtVcProofSuite:
    """SD-JWT VC issuance, holder presentation, and verification."""

    def __init__(
        self, *, leeway_s: int = DEFAULT_LEEWAY_S, hash_name: str = _DEFAULT_HASH,
        allow_pq: bool = False,
    ) -> None:
        if hash_name not in _HASHES:
            raise SdJwtError(f"unsupported hash {hash_name!r}")
        self._leeway = leeway_s
        self._hash_name = hash_name
        # allow_pq merges the EXPERIMENTAL ML-DSA algs into this suite's allow-list; the
        # default suite rejects ML-DSA at the allow-list, before any crypto (ADR-0004).
        self._algs = ALLOWED_ALGS | ALLOWED_ALGS_PQ if allow_pq else ALLOWED_ALGS

    # -- untrusted inspection --------------------------------------------- #

    def peek_issuer(self, sd_jwt: str) -> tuple[str, str | None]:
        """Return (iss, kid) from the issuer-signed JWT WITHOUT verifying.
        UNTRUSTED — use only to select which key to resolve."""
        header, payload, _, _ = self._decode_jws(sd_jwt.split("~", 1)[0])
        iss = payload.get("iss")
        if not iss or not isinstance(iss, str):
            raise MalformedToken("no iss in the issuer-signed JWT")
        return iss, header.get("kid")

    # -- issuance --------------------------------------------------------- #

    def issue(
        self,
        claims: dict[str, Any],
        *,
        signing_key: SigningKey,
        disclosable: Iterable[str] = (),
        holder_jwk: dict[str, Any] | None = None,
        vct: str | None = None,
        expires_in_s: int | None = None,
        decoys: int = 0,
        x5c: Iterable[str] | None = None,
    ) -> str:
        """Issue an SD-JWT VC: ``<issuer-jwt>~<disclosure>~...~``.

        ``disclosable`` names the top-level claims to make selectively
        disclosable (each leaves the JWT for a disclosure + an ``_sd`` digest).
        ``holder_jwk`` binds the credential to a holder key (``cnf``) for later
        key binding. ``decoys`` adds that many decoy digests (privacy). The raw
        signature is produced by ``signing_key`` — HSM/Vault friendly.

        ``x5c`` places an X.509 certificate chain (base64 DER, leaf first) in the
        issuer JWT header, so a verifier that anchors trust in a trusted list can
        validate it in one call: ``verify_credential(sd_jwt, x5c_trust_anchors=[…])``
        chains the leaf to those anchors and binds it to ``iss`` (see :mod:`openvc.x5c`).
        The leaf certificate's key MUST be the *signing_key*'s public key, and ``iss``
        must appear in the leaf's Subject Alternative Name, or verification fails closed.
        """
        if signing_key.alg not in self._algs:
            raise UnsupportedAlgorithm(f"key alg {signing_key.alg!r} not permitted")

        now = int(time.time())
        payload: dict[str, Any] = dict(claims)
        payload.setdefault("iat", now)
        if vct is not None:
            payload["vct"] = vct
        if not payload.get("iss"):
            raise SdJwtError("an SD-JWT VC needs an 'iss' claim")
        if not payload.get("vct"):
            raise SdJwtError("an SD-JWT VC needs a 'vct' claim")
        if expires_in_s is not None:
            payload["exp"] = now + expires_in_s
        if holder_jwk is not None:
            payload["cnf"] = {"jwk": holder_jwk}

        disclosures: list[str] = []
        digests: list[str] = []
        for name in disclosable:
            if name not in payload:
                raise SdJwtError(f"disclosable claim {name!r} is not in the claims")
            disclosure = make_object_disclosure(
                _b64url_encode(secrets.token_bytes(_SALT_BYTES)), name, payload.pop(name))
            disclosures.append(disclosure)
            digests.append(disclosure_digest(disclosure, hash_name=self._hash_name))
        digest_size = _HASHES[self._hash_name]().digest_size
        for _ in range(decoys):
            digests.append(_b64url_encode(secrets.token_bytes(digest_size)))
        if digests:
            payload["_sd"] = sorted(digests)           # sorted: order leaks nothing
            payload["_sd_alg"] = self._hash_name

        header: dict[str, Any] = {
            "typ": _ISSUER_TYP, "alg": signing_key.alg, "kid": signing_key.kid}
        if x5c is not None:
            chain = list(x5c)
            if not chain or not all(isinstance(c, str) and c for c in chain):
                raise SdJwtError("x5c must be a non-empty list of base64 certificate strings")
            header["x5c"] = chain
        issuer_jwt = self._sign_compact(header, payload, signing_key)
        return issuer_jwt + "~" + "".join(d + "~" for d in disclosures)

    # -- holder presentation ---------------------------------------------- #

    def create_presentation(
        self,
        sd_jwt: str,
        *,
        holder_key: SigningKey,
        audience: str,
        nonce: str,
    ) -> str:
        """Holder side: attach a Key Binding JWT over the (all-disclosure)
        presentation, bound to *audience* and *nonce*. Returns the full
        ``<issuer-jwt>~<disclosures>~<KB-JWT>``."""
        if holder_key.alg not in self._algs:
            raise UnsupportedAlgorithm(f"holder key alg {holder_key.alg!r} not permitted")
        issuer_jwt, disclosures, _ = self._split(sd_jwt)
        presented = issuer_jwt + "~" + "".join(d + "~" for d in disclosures)
        sd_hash = _b64url_encode(
            _HASHES[self._hash_name](presented.encode("ascii")).digest())
        header = {"typ": _KB_TYP, "alg": holder_key.alg}
        payload = {"iat": int(time.time()), "aud": audience, "nonce": nonce, "sd_hash": sd_hash}
        return presented + self._sign_compact(header, payload, holder_key)

    # -- verification ------------------------------------------------------ #

    def verify(
        self,
        presentation: str,
        *,
        public_key_jwk: dict[str, Any],
        audience: str | None = None,
        nonce: str | None = None,
        require_key_binding: bool = False,
        expected_vct: str | None = None,
    ) -> VerifiedSdJwt:
        """Verify an SD-JWT (VC) presentation end to end.

        Verifies the issuer signature + temporal claims, validates and unpacks the
        disclosures, and — if a KB-JWT is present (or required) — verifies it
        against the holder key in ``cnf`` and checks ``aud`` / ``nonce`` /
        ``sd_hash``.
        """
        issuer_jwt, disclosures, kb_jwt = self._split(presentation)
        header, claims, signing_input, signature = self._decode_jws(issuer_jwt)

        alg = header.get("alg")
        if alg not in self._algs:                     # allow-list BEFORE crypto
            raise UnsupportedAlgorithm(f"algorithm {alg!r} is not permitted")
        if header.get("typ") not in _ACCEPTED_ISSUER_TYP:
            raise SdJwtError(f"unexpected issuer JWT typ {header.get('typ')!r}")
        reject_unknown_crit(header)
        self._verify_signature(alg, public_key_jwk, signing_input, signature,
                               what="issuer JWT")
        self._check_temporal(claims)

        iss = claims.get("iss")
        if not isinstance(iss, str):
            raise ClaimsInvalid("iss claim is missing or not a string")

        hash_name = claims.get("_sd_alg", _DEFAULT_HASH)
        if hash_name not in _HASHES:
            raise SdJwtError(f"unsupported _sd_alg {hash_name!r}")
        by_digest = self._index_disclosures(disclosures, hash_name)
        used: set[str] = set()
        unpacked = _unpack(claims, by_digest, used, set())
        unreferenced = set(by_digest) - used
        if unreferenced:
            raise SdJwtError(
                f"{len(unreferenced)} disclosure(s) not referenced by any digest")
        unpacked.pop("_sd_alg", None)

        vct = unpacked.get("vct")
        if expected_vct is not None and vct != expected_vct:
            raise ClaimsInvalid(f"vct {vct!r} != expected {expected_vct!r}")

        key_bound = self._verify_key_binding(
            kb_jwt, issuer_jwt, disclosures, claims.get("cnf"),
            hash_name=hash_name, audience=audience, nonce=nonce,
            required=require_key_binding)

        return VerifiedSdJwt(
            claims=unpacked, issuer=iss, vct=vct if isinstance(vct, str) else None,
            key_bound=key_bound, confirmation=claims.get("cnf"))

    # -- internals --------------------------------------------------------- #

    @staticmethod
    def _split(sd_jwt: str) -> tuple[str, list[str], str]:
        """(issuer-jwt, [disclosures], kb-jwt) — kb is '' when absent."""
        parts = sd_jwt.split("~")
        if len(parts) < 2:
            raise MalformedToken("not an SD-JWT (no '~' separator)")
        return parts[0], [p for p in parts[1:-1] if p], parts[-1]

    @staticmethod
    def _decode_jws(jws: str) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]:
        """(header, payload, signing_input, signature) for a compact JWS."""
        try:
            header_b64, payload_b64, sig_b64 = jws.split(".")
            header = json.loads(_b64url_decode(header_b64))
            payload = json.loads(_b64url_decode(payload_b64))
            signature = _b64url_decode(sig_b64)
        except (ValueError, json.JSONDecodeError, RecursionError) as exc:
            # RecursionError: a deeply-nested (valid) JSON header/payload overruns the
            # interpreter limit; catch it here (the stack has unwound to this frame) and
            # surface it typed, so it never escapes OpenvcError and aborts verify_many.
            raise MalformedToken("not a valid compact JWS") from exc
        if not isinstance(header, dict) or not isinstance(payload, dict):
            # A JOSE header/payload that is valid JSON but not an object (e.g. `[0]`)
            # must fail closed as a typed MalformedToken, never a bare AttributeError
            # on the peek path — which would escape OpenvcError and abort verify_many.
            raise MalformedToken("JWS header and payload must be JSON objects")
        signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
        return header, payload, signing_input, signature

    @staticmethod
    def _sign_compact(header: dict[str, Any], payload: dict[str, Any], key: SigningKey) -> str:
        signing_input = (
            f"{_b64url_encode(_json_bytes(header))}.{_b64url_encode(_json_bytes(payload))}")
        signature = key.sign(signing_input.encode("ascii"))
        return f"{signing_input}.{_b64url_encode(signature)}"

    @staticmethod
    def _verify_signature(
        alg: str, jwk: dict[str, Any], signing_input: bytes, signature: bytes, *, what: str
    ) -> None:
        try:
            ok = verify_signature(
                alg=alg, public_jwk=jwk, signing_input=signing_input, signature=signature)
        except KeyBackendError as exc:
            raise ProofError(f"could not verify {what}: {exc}") from exc
        if not ok:
            raise SignatureInvalid(f"{what} signature failed")

    def _check_temporal(self, claims: dict[str, Any]) -> None:
        # NumericDate exp/nbf, fail-closed and non-finite-safe, single-sourced across the
        # JOSE suites (openvc.proof._verify_common.check_jwt_temporal).
        check_jwt_temporal(claims, leeway_s=self._leeway, subject="token")

    def _index_disclosures(self, disclosures: list[str], hash_name: str) -> dict[str, list]:
        by_digest: dict[str, list] = {}
        for disclosure in disclosures:
            try:
                parsed = json.loads(_b64url_decode(disclosure))
            except (ValueError, json.JSONDecodeError, RecursionError) as exc:
                raise SdJwtError("a disclosure is not valid base64url JSON") from exc
            if not isinstance(parsed, list) or len(parsed) not in (2, 3):
                raise SdJwtError("a disclosure must be a 2- or 3-element array")
            digest = disclosure_digest(disclosure, hash_name=hash_name)
            if digest in by_digest:
                raise SdJwtError("duplicate disclosure in presentation")
            by_digest[digest] = parsed
        return by_digest

    def _verify_key_binding(
        self,
        kb_jwt: str,
        issuer_jwt: str,
        disclosures: list[str],
        cnf: Any,
        *,
        hash_name: str,
        audience: str | None,
        nonce: str | None,
        required: bool,
    ) -> bool:
        if required and (audience is None or nonce is None):
            # A KB-JWT proves possession of the holder key and that these disclosures were
            # presented, but only the aud+nonce bind it to THIS verifier and challenge.
            # Requiring key binding without supplying them gives no replay protection —
            # a presentation built for verifier A would satisfy verifier B. Fail closed,
            # matching VP-JWT's "no unbound mode".
            raise ClaimsInvalid(
                "key binding required but no audience/nonce given to bind it against "
                "(a KB-JWT without a verifier nonce and aud does not prevent replay)")
        if not kb_jwt:
            if required:
                raise ClaimsInvalid("key binding required but no KB-JWT present")
            return False
        header, claims, signing_input, signature = self._decode_jws(kb_jwt)
        if header.get("typ") != _KB_TYP:
            raise SdJwtError(f"KB-JWT typ must be {_KB_TYP!r}")
        alg = header.get("alg")
        if alg not in self._algs:
            raise UnsupportedAlgorithm(f"KB-JWT algorithm {alg!r} is not permitted")
        reject_unknown_crit(header)
        if not isinstance(cnf, dict) or not isinstance(cnf.get("jwk"), dict):
            raise ClaimsInvalid("no cnf.jwk in the issuer JWT for key binding")
        self._verify_signature(alg, cnf["jwk"], signing_input, signature, what="KB-JWT")

        if audience is not None and claims.get("aud") != audience:
            raise ClaimsInvalid("KB-JWT aud does not match")
        if nonce is not None and claims.get("nonce") != nonce:
            raise ClaimsInvalid("KB-JWT nonce does not match")
        presented = issuer_jwt + "~" + "".join(d + "~" for d in disclosures)
        expected = _b64url_encode(_HASHES[hash_name](presented.encode("ascii")).digest())
        if claims.get("sd_hash") != expected:
            raise ClaimsInvalid("KB-JWT sd_hash does not match the presented disclosures")
        return True

peek_issuer(sd_jwt)

Return (iss, kid) from the issuer-signed JWT WITHOUT verifying. UNTRUSTED — use only to select which key to resolve.

Source code in src/openvc/proof/sd_jwt.py
215
216
217
218
219
220
221
222
def peek_issuer(self, sd_jwt: str) -> tuple[str, str | None]:
    """Return (iss, kid) from the issuer-signed JWT WITHOUT verifying.
    UNTRUSTED — use only to select which key to resolve."""
    header, payload, _, _ = self._decode_jws(sd_jwt.split("~", 1)[0])
    iss = payload.get("iss")
    if not iss or not isinstance(iss, str):
        raise MalformedToken("no iss in the issuer-signed JWT")
    return iss, header.get("kid")

issue(claims, *, signing_key, disclosable=(), holder_jwk=None, vct=None, expires_in_s=None, decoys=0, x5c=None)

Issue an SD-JWT VC: <issuer-jwt>~<disclosure>~...~.

disclosable names the top-level claims to make selectively disclosable (each leaves the JWT for a disclosure + an _sd digest). holder_jwk binds the credential to a holder key (cnf) for later key binding. decoys adds that many decoy digests (privacy). The raw signature is produced by signing_key — HSM/Vault friendly.

x5c places an X.509 certificate chain (base64 DER, leaf first) in the issuer JWT header, so a verifier that anchors trust in a trusted list can validate it in one call: verify_credential(sd_jwt, x5c_trust_anchors=[…]) chains the leaf to those anchors and binds it to iss (see :mod:openvc.x5c). The leaf certificate's key MUST be the signing_key's public key, and iss must appear in the leaf's Subject Alternative Name, or verification fails closed.

Source code in src/openvc/proof/sd_jwt.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def issue(
    self,
    claims: dict[str, Any],
    *,
    signing_key: SigningKey,
    disclosable: Iterable[str] = (),
    holder_jwk: dict[str, Any] | None = None,
    vct: str | None = None,
    expires_in_s: int | None = None,
    decoys: int = 0,
    x5c: Iterable[str] | None = None,
) -> str:
    """Issue an SD-JWT VC: ``<issuer-jwt>~<disclosure>~...~``.

    ``disclosable`` names the top-level claims to make selectively
    disclosable (each leaves the JWT for a disclosure + an ``_sd`` digest).
    ``holder_jwk`` binds the credential to a holder key (``cnf``) for later
    key binding. ``decoys`` adds that many decoy digests (privacy). The raw
    signature is produced by ``signing_key`` — HSM/Vault friendly.

    ``x5c`` places an X.509 certificate chain (base64 DER, leaf first) in the
    issuer JWT header, so a verifier that anchors trust in a trusted list can
    validate it in one call: ``verify_credential(sd_jwt, x5c_trust_anchors=[…])``
    chains the leaf to those anchors and binds it to ``iss`` (see :mod:`openvc.x5c`).
    The leaf certificate's key MUST be the *signing_key*'s public key, and ``iss``
    must appear in the leaf's Subject Alternative Name, or verification fails closed.
    """
    if signing_key.alg not in self._algs:
        raise UnsupportedAlgorithm(f"key alg {signing_key.alg!r} not permitted")

    now = int(time.time())
    payload: dict[str, Any] = dict(claims)
    payload.setdefault("iat", now)
    if vct is not None:
        payload["vct"] = vct
    if not payload.get("iss"):
        raise SdJwtError("an SD-JWT VC needs an 'iss' claim")
    if not payload.get("vct"):
        raise SdJwtError("an SD-JWT VC needs a 'vct' claim")
    if expires_in_s is not None:
        payload["exp"] = now + expires_in_s
    if holder_jwk is not None:
        payload["cnf"] = {"jwk": holder_jwk}

    disclosures: list[str] = []
    digests: list[str] = []
    for name in disclosable:
        if name not in payload:
            raise SdJwtError(f"disclosable claim {name!r} is not in the claims")
        disclosure = make_object_disclosure(
            _b64url_encode(secrets.token_bytes(_SALT_BYTES)), name, payload.pop(name))
        disclosures.append(disclosure)
        digests.append(disclosure_digest(disclosure, hash_name=self._hash_name))
    digest_size = _HASHES[self._hash_name]().digest_size
    for _ in range(decoys):
        digests.append(_b64url_encode(secrets.token_bytes(digest_size)))
    if digests:
        payload["_sd"] = sorted(digests)           # sorted: order leaks nothing
        payload["_sd_alg"] = self._hash_name

    header: dict[str, Any] = {
        "typ": _ISSUER_TYP, "alg": signing_key.alg, "kid": signing_key.kid}
    if x5c is not None:
        chain = list(x5c)
        if not chain or not all(isinstance(c, str) and c for c in chain):
            raise SdJwtError("x5c must be a non-empty list of base64 certificate strings")
        header["x5c"] = chain
    issuer_jwt = self._sign_compact(header, payload, signing_key)
    return issuer_jwt + "~" + "".join(d + "~" for d in disclosures)

create_presentation(sd_jwt, *, holder_key, audience, nonce)

Holder side: attach a Key Binding JWT over the (all-disclosure) presentation, bound to audience and nonce. Returns the full <issuer-jwt>~<disclosures>~<KB-JWT>.

Source code in src/openvc/proof/sd_jwt.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def create_presentation(
    self,
    sd_jwt: str,
    *,
    holder_key: SigningKey,
    audience: str,
    nonce: str,
) -> str:
    """Holder side: attach a Key Binding JWT over the (all-disclosure)
    presentation, bound to *audience* and *nonce*. Returns the full
    ``<issuer-jwt>~<disclosures>~<KB-JWT>``."""
    if holder_key.alg not in self._algs:
        raise UnsupportedAlgorithm(f"holder key alg {holder_key.alg!r} not permitted")
    issuer_jwt, disclosures, _ = self._split(sd_jwt)
    presented = issuer_jwt + "~" + "".join(d + "~" for d in disclosures)
    sd_hash = _b64url_encode(
        _HASHES[self._hash_name](presented.encode("ascii")).digest())
    header = {"typ": _KB_TYP, "alg": holder_key.alg}
    payload = {"iat": int(time.time()), "aud": audience, "nonce": nonce, "sd_hash": sd_hash}
    return presented + self._sign_compact(header, payload, holder_key)

verify(presentation, *, public_key_jwk, audience=None, nonce=None, require_key_binding=False, expected_vct=None)

Verify an SD-JWT (VC) presentation end to end.

Verifies the issuer signature + temporal claims, validates and unpacks the disclosures, and — if a KB-JWT is present (or required) — verifies it against the holder key in cnf and checks aud / nonce / sd_hash.

Source code in src/openvc/proof/sd_jwt.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def verify(
    self,
    presentation: str,
    *,
    public_key_jwk: dict[str, Any],
    audience: str | None = None,
    nonce: str | None = None,
    require_key_binding: bool = False,
    expected_vct: str | None = None,
) -> VerifiedSdJwt:
    """Verify an SD-JWT (VC) presentation end to end.

    Verifies the issuer signature + temporal claims, validates and unpacks the
    disclosures, and — if a KB-JWT is present (or required) — verifies it
    against the holder key in ``cnf`` and checks ``aud`` / ``nonce`` /
    ``sd_hash``.
    """
    issuer_jwt, disclosures, kb_jwt = self._split(presentation)
    header, claims, signing_input, signature = self._decode_jws(issuer_jwt)

    alg = header.get("alg")
    if alg not in self._algs:                     # allow-list BEFORE crypto
        raise UnsupportedAlgorithm(f"algorithm {alg!r} is not permitted")
    if header.get("typ") not in _ACCEPTED_ISSUER_TYP:
        raise SdJwtError(f"unexpected issuer JWT typ {header.get('typ')!r}")
    reject_unknown_crit(header)
    self._verify_signature(alg, public_key_jwk, signing_input, signature,
                           what="issuer JWT")
    self._check_temporal(claims)

    iss = claims.get("iss")
    if not isinstance(iss, str):
        raise ClaimsInvalid("iss claim is missing or not a string")

    hash_name = claims.get("_sd_alg", _DEFAULT_HASH)
    if hash_name not in _HASHES:
        raise SdJwtError(f"unsupported _sd_alg {hash_name!r}")
    by_digest = self._index_disclosures(disclosures, hash_name)
    used: set[str] = set()
    unpacked = _unpack(claims, by_digest, used, set())
    unreferenced = set(by_digest) - used
    if unreferenced:
        raise SdJwtError(
            f"{len(unreferenced)} disclosure(s) not referenced by any digest")
    unpacked.pop("_sd_alg", None)

    vct = unpacked.get("vct")
    if expected_vct is not None and vct != expected_vct:
        raise ClaimsInvalid(f"vct {vct!r} != expected {expected_vct!r}")

    key_bound = self._verify_key_binding(
        kb_jwt, issuer_jwt, disclosures, claims.get("cnf"),
        hash_name=hash_name, audience=audience, nonce=nonce,
        required=require_key_binding)

    return VerifiedSdJwt(
        claims=unpacked, issuer=iss, vct=vct if isinstance(vct, str) else None,
        key_bound=key_bound, confirmation=claims.get("cnf"))

make_object_disclosure(salt, name, value)

Encode an object-property disclosure [salt, name, value].

Source code in src/openvc/proof/sd_jwt.py
89
90
91
def make_object_disclosure(salt: str, name: str, value: Any) -> str:
    """Encode an object-property disclosure ``[salt, name, value]``."""
    return _b64url_encode(_json_bytes([salt, name, value]))

make_array_disclosure(salt, value)

Encode an array-element disclosure [salt, value].

Source code in src/openvc/proof/sd_jwt.py
94
95
96
def make_array_disclosure(salt: str, value: Any) -> str:
    """Encode an array-element disclosure ``[salt, value]``."""
    return _b64url_encode(_json_bytes([salt, value]))

disclosure_digest(disclosure, *, hash_name=_DEFAULT_HASH)

The base64url digest of a disclosure string — hashed exactly as received (never re-serialized), which is why the verifier needs no canonicalization.

Source code in src/openvc/proof/sd_jwt.py
 99
100
101
102
103
104
105
106
def disclosure_digest(disclosure: str, *, hash_name: str = _DEFAULT_HASH) -> str:
    """The base64url digest of a disclosure string — hashed exactly as received
    (never re-serialized), which is why the verifier needs no canonicalization."""
    try:
        hasher = _HASHES[hash_name]
    except KeyError as exc:
        raise SdJwtError(f"unsupported _sd_alg {hash_name!r}") from exc
    return _b64url_encode(hasher(disclosure.encode("ascii")).digest())

Data Integrity — eddsa-rdfc-2022

openvc.proof.data_integrity

openvc.proof.data_integrity — Data Integrity proof suite, cryptosuite eddsa-rdfc-2022 (W3C VC Data Integrity + EdDSA Cryptosuites).

The second proof profile alongside :mod:openvc.proof.vc_jwt. Where VC-JWT wraps a credential in a JOSE token, a Data Integrity proof is embedded in the credential's own JSON as a proof object, and integrity is computed over the RDF canonical form (RDFC-1.0 / URDNA2015) rather than the raw bytes — so it survives re-serialization.

Algorithm (vc-di-eddsa §3.3, eddsa-rdfc-2022):

  1. proofConfig = the proof object without proofValue, carrying the document's @context; canonicalize to N-Quads, SHA-256 it.
  2. the unsecured document (proof removed); canonicalize, SHA-256 it.
  3. hashData = proofConfigHash ‖ documentHash (64 bytes).
  4. Ed25519-sign hashData; proofValue = 'z' + base58btc(signature).

Signing goes through the :class:~openvc.proof.vc_jwt.SigningKey protocol (so an HSM/Vault Ed25519 key drops in). Requires the optional pyld dependency (the [data-integrity] extra) for canonicalization; importing this module without it is fine, only sign/verify need it.

CredentialExpired

Bases: ProofError

The credential's validity window has ended (validUntil / expirationDate / proof expires).

Source code in src/openvc/proof/errors.py
53
54
class CredentialExpired(ProofError):
    """The credential's validity window has ended (validUntil / expirationDate / proof expires)."""

CredentialNotYetValid

Bases: ProofError

The credential's validity window has not started (validFrom / issuanceDate).

Source code in src/openvc/proof/errors.py
57
58
class CredentialNotYetValid(ProofError):
    """The credential's validity window has not started (validFrom / issuanceDate)."""

KeyResolutionError

Bases: ProofError

The proof's verificationMethod key could not be resolved.

Source code in src/openvc/proof/errors.py
69
70
class KeyResolutionError(ProofError):
    """The proof's verificationMethod key could not be resolved."""

MalformedTimestamp

Bases: ProofError

A validity timestamp is present but not a parseable date-time (fails closed).

Source code in src/openvc/proof/errors.py
61
62
class MalformedTimestamp(ProofError):
    """A validity timestamp is present but not a parseable date-time (fails closed)."""

PresentationBindingError

Bases: ProofError

A presentation proof's challenge / domain does not match what the verifier expects.

Source code in src/openvc/proof/errors.py
73
74
class PresentationBindingError(ProofError):
    """A presentation proof's challenge / domain does not match what the verifier expects."""

ProofPurposeMismatch

Bases: ProofError

The proof's proofPurpose is not the expected one (e.g. assertionMethod).

Source code in src/openvc/proof/errors.py
65
66
class ProofPurposeMismatch(ProofError):
    """The proof's proofPurpose is not the expected one (e.g. assertionMethod)."""

ProofMalformed

Bases: ProofError

The proof object is structurally invalid — missing or wrongly typed fields.

Source code in src/openvc/proof/errors.py
31
32
class ProofMalformed(ProofError):
    """The proof object is structurally invalid — missing or wrongly typed fields."""

SignatureInvalid

Bases: ProofError

A proof/signature did not verify.

Source code in src/openvc/proof/errors.py
27
28
class SignatureInvalid(ProofError):
    """A proof/signature did not verify."""

UnsupportedCryptosuite

Bases: ProofError

The proof declares a cryptosuite this suite does not implement.

Source code in src/openvc/proof/errors.py
35
36
class UnsupportedCryptosuite(ProofError):
    """The proof declares a cryptosuite this suite does not implement."""

DataIntegrityProofSuite

Sign and verify credentials with an embedded eddsa-rdfc-2022 proof.

Source code in src/openvc/proof/data_integrity.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
class DataIntegrityProofSuite:
    """Sign and verify credentials with an embedded eddsa-rdfc-2022 proof."""

    def __init__(self, *, leeway_s: int = DEFAULT_LEEWAY_S) -> None:
        self._leeway = leeway_s

    def add_proof(
        self,
        credential: dict[str, Any],
        *,
        signing_key: SigningKey,
        verification_method: str,
        proof_purpose: str = "assertionMethod",
        challenge: str | None = None,
        domain: str | None = None,
        created: datetime | None = None,
        extra_contexts: Mapping[str, dict] | None = None,
    ) -> dict[str, Any]:
        """Return a copy of *credential* secured with a Data Integrity proof.

        *verification_method* is embedded verbatim (a did:key / did:web URL a
        verifier can resolve). For a presentation proof (``proof_purpose=
        "authentication"``) pass *challenge* / *domain* to bind it to a verifier
        session; both are covered by the signature. The input is not mutated.
        """
        if signing_key.alg != _ED25519_ALG:
            raise UnsupportedCryptosuite(
                f"eddsa-rdfc-2022 requires an Ed25519 (EdDSA) key, got "
                f"{signing_key.alg!r}")
        if "@context" not in credential:
            raise ProofMalformed("credential has no @context to canonicalize against")
        if "proof" in credential:
            raise ProofMalformed("credential already carries a proof")

        proof = {
            "type": PROOF_TYPE,
            "cryptosuite": CRYPTOSUITE,
            "created": _iso(created if created is not None else datetime.now(timezone.utc)),
            "verificationMethod": verification_method,
            "proofPurpose": proof_purpose,
        }
        if challenge is not None:
            proof["challenge"] = challenge
        if domain is not None:
            proof["domain"] = domain
        proof_config = dict(proof)
        proof_config["@context"] = credential["@context"]

        loader = document_loader(extra_contexts)
        data = _hash_data(_unsecured(credential), proof_config, loader)
        signature = signing_key.sign(data)           # raw 64-byte Ed25519

        secured = copy.deepcopy(credential)
        secured["proof"] = dict(proof, proofValue=encode_multibase(signature))
        return secured

    def verify(
        self,
        secured: dict[str, Any],
        *,
        public_key_jwk: dict[str, Any] | None = None,
        resolver: Any = None,
        expected_proof_purpose: str | None = "assertionMethod",
        expected_challenge: str | None = None,
        expected_domain: str | None = None,
        now: datetime | None = None,
        extra_contexts: Mapping[str, dict] | None = None,
    ) -> VerifiedDataIntegrity:
        """Verify the embedded proof end to end.

        Key selection: with *public_key_jwk* the proof must verify against that
        operator-trusted key; otherwise the key is resolved from the proof's
        ``verificationMethod`` — via *resolver* (a ``DidResolver`` /
        ``DidResolverRegistry``, e.g. to reach ``did:web``) when given, falling
        back to offline ``did:key``. A resolved key must be authorized by the DID
        document for *expected_proof_purpose*.

        Policy (checked after the signature verifies): the proof's
        ``proofPurpose`` must equal *expected_proof_purpose* (pass ``None`` to
        skip), and the credential's validity window
        (``validFrom``/``validUntil`` or ``issuanceDate``/``expirationDate``) plus
        the proof's ``expires`` must contain *now* (default: current time) within
        the suite's leeway.
        """
        proof, proof_config, signature = prepare_di_proof(
            secured, proof_type=PROOF_TYPE, cryptosuite=CRYPTOSUITE)

        loader = document_loader(extra_contexts)
        unsecured = _unsecured(secured)
        data = _hash_data(unsecured, proof_config, loader)

        jwk = public_key_jwk or resolve_verification_key(
            proof.get("verificationMethod"),
            proof_purpose=proof.get("proofPurpose"),
            resolver=resolver,
        )
        if not _verify_ed25519(jwk, data, signature):
            raise SignatureInvalid("Data Integrity proof does not verify")

        check_proof_purpose(proof, expected_proof_purpose)
        check_presentation_binding(
            proof, expected_challenge=expected_challenge, expected_domain=expected_domain)
        check_validity_window(unsecured, proof, now=now, leeway_s=self._leeway)

        issuer = secured.get("issuer")
        issuer = issuer.get("id") if isinstance(issuer, dict) else issuer
        subject = (secured.get("credentialSubject") or {})
        subject_id = subject.get("id") if isinstance(subject, dict) else None
        return VerifiedDataIntegrity(
            credential=secured, issuer=issuer, subject=subject_id, proof=proof)

add_proof(credential, *, signing_key, verification_method, proof_purpose='assertionMethod', challenge=None, domain=None, created=None, extra_contexts=None)

Return a copy of credential secured with a Data Integrity proof.

verification_method is embedded verbatim (a did:key / did:web URL a verifier can resolve). For a presentation proof (proof_purpose= "authentication") pass challenge / domain to bind it to a verifier session; both are covered by the signature. The input is not mutated.

Source code in src/openvc/proof/data_integrity.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def add_proof(
    self,
    credential: dict[str, Any],
    *,
    signing_key: SigningKey,
    verification_method: str,
    proof_purpose: str = "assertionMethod",
    challenge: str | None = None,
    domain: str | None = None,
    created: datetime | None = None,
    extra_contexts: Mapping[str, dict] | None = None,
) -> dict[str, Any]:
    """Return a copy of *credential* secured with a Data Integrity proof.

    *verification_method* is embedded verbatim (a did:key / did:web URL a
    verifier can resolve). For a presentation proof (``proof_purpose=
    "authentication"``) pass *challenge* / *domain* to bind it to a verifier
    session; both are covered by the signature. The input is not mutated.
    """
    if signing_key.alg != _ED25519_ALG:
        raise UnsupportedCryptosuite(
            f"eddsa-rdfc-2022 requires an Ed25519 (EdDSA) key, got "
            f"{signing_key.alg!r}")
    if "@context" not in credential:
        raise ProofMalformed("credential has no @context to canonicalize against")
    if "proof" in credential:
        raise ProofMalformed("credential already carries a proof")

    proof = {
        "type": PROOF_TYPE,
        "cryptosuite": CRYPTOSUITE,
        "created": _iso(created if created is not None else datetime.now(timezone.utc)),
        "verificationMethod": verification_method,
        "proofPurpose": proof_purpose,
    }
    if challenge is not None:
        proof["challenge"] = challenge
    if domain is not None:
        proof["domain"] = domain
    proof_config = dict(proof)
    proof_config["@context"] = credential["@context"]

    loader = document_loader(extra_contexts)
    data = _hash_data(_unsecured(credential), proof_config, loader)
    signature = signing_key.sign(data)           # raw 64-byte Ed25519

    secured = copy.deepcopy(credential)
    secured["proof"] = dict(proof, proofValue=encode_multibase(signature))
    return secured

verify(secured, *, public_key_jwk=None, resolver=None, expected_proof_purpose='assertionMethod', expected_challenge=None, expected_domain=None, now=None, extra_contexts=None)

Verify the embedded proof end to end.

Key selection: with public_key_jwk the proof must verify against that operator-trusted key; otherwise the key is resolved from the proof's verificationMethod — via resolver (a DidResolver / DidResolverRegistry, e.g. to reach did:web) when given, falling back to offline did:key. A resolved key must be authorized by the DID document for expected_proof_purpose.

Policy (checked after the signature verifies): the proof's proofPurpose must equal expected_proof_purpose (pass None to skip), and the credential's validity window (validFrom/validUntil or issuanceDate/expirationDate) plus the proof's expires must contain now (default: current time) within the suite's leeway.

Source code in src/openvc/proof/data_integrity.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def verify(
    self,
    secured: dict[str, Any],
    *,
    public_key_jwk: dict[str, Any] | None = None,
    resolver: Any = None,
    expected_proof_purpose: str | None = "assertionMethod",
    expected_challenge: str | None = None,
    expected_domain: str | None = None,
    now: datetime | None = None,
    extra_contexts: Mapping[str, dict] | None = None,
) -> VerifiedDataIntegrity:
    """Verify the embedded proof end to end.

    Key selection: with *public_key_jwk* the proof must verify against that
    operator-trusted key; otherwise the key is resolved from the proof's
    ``verificationMethod`` — via *resolver* (a ``DidResolver`` /
    ``DidResolverRegistry``, e.g. to reach ``did:web``) when given, falling
    back to offline ``did:key``. A resolved key must be authorized by the DID
    document for *expected_proof_purpose*.

    Policy (checked after the signature verifies): the proof's
    ``proofPurpose`` must equal *expected_proof_purpose* (pass ``None`` to
    skip), and the credential's validity window
    (``validFrom``/``validUntil`` or ``issuanceDate``/``expirationDate``) plus
    the proof's ``expires`` must contain *now* (default: current time) within
    the suite's leeway.
    """
    proof, proof_config, signature = prepare_di_proof(
        secured, proof_type=PROOF_TYPE, cryptosuite=CRYPTOSUITE)

    loader = document_loader(extra_contexts)
    unsecured = _unsecured(secured)
    data = _hash_data(unsecured, proof_config, loader)

    jwk = public_key_jwk or resolve_verification_key(
        proof.get("verificationMethod"),
        proof_purpose=proof.get("proofPurpose"),
        resolver=resolver,
    )
    if not _verify_ed25519(jwk, data, signature):
        raise SignatureInvalid("Data Integrity proof does not verify")

    check_proof_purpose(proof, expected_proof_purpose)
    check_presentation_binding(
        proof, expected_challenge=expected_challenge, expected_domain=expected_domain)
    check_validity_window(unsecured, proof, now=now, leeway_s=self._leeway)

    issuer = secured.get("issuer")
    issuer = issuer.get("id") if isinstance(issuer, dict) else issuer
    subject = (secured.get("credentialSubject") or {})
    subject_id = subject.get("id") if isinstance(subject, dict) else None
    return VerifiedDataIntegrity(
        credential=secured, issuer=issuer, subject=subject_id, proof=proof)

Data Integrity — ecdsa-rdfc-2019 (P-256 / P-384)

openvc.proof.di_ecdsa_rdfc

openvc.proof.di_ecdsa_rdfc — the ECDSA RDF Data Integrity cryptosuite ecdsa-rdfc-2019 (W3C VC Data Integrity ECDSA Cryptosuites v1.0).

The ECDSA analogue of :class:~openvc.proof.data_integrity.DataIntegrityProofSuite (eddsa-rdfc-2022): same RDF N-Quads canonicalization (RDFC-1.0 / URDNA2015, via pyld) and the same config-first hashData ::

hashData = H(canonicalize(proofConfig)) ‖ H(canonicalize(unsecuredDocument))

but it signs ECDSA instead of Ed25519 — P-256/SHA-256 (ES256) or P-384/SHA-384 (ES384), raw R‖S like the JOSE path, the digest chosen by the key's curve (vc-di-ecdsa §3.x). It is to eddsa-rdfc-2022 what :class:~openvc.proof.di_jcs.EcdsaJcsProofSuite (ecdsa-jcs-2019) is to eddsa-jcs-2022, only over RDF rather than JCS — so it reuses the RDF canonicalization helpers from :mod:openvc.proof.data_integrity and the multi-curve ECDSA key handling shape from :mod:openvc.proof.di_jcs.

Signing goes through the :class:~openvc.proof.vc_jwt.SigningKey protocol (so an HSM/Vault P-256/P-384 key drops in). Like the RDF eddsa-rdfc-2022 path — and unlike the JCS suites — it needs the optional pyld dependency (the [data-integrity] extra) for canonicalization; importing this module without it is fine, only sign/verify need it.

EcdsaRdfcProofSuite

Sign and verify credentials with an embedded ecdsa-rdfc-2019 proof.

Whole-document (non-selective) ECDSA Data Integrity over RDF N-Quads — P-256/SHA-256 (ES256) or P-384/SHA-384 (ES384), selected by the signing/verification key's curve. The RDF sibling of :class:~openvc.proof.di_jcs.EcdsaJcsProofSuite; needs pyld (the [data-integrity] extra), which the JCS suite does not.

Source code in src/openvc/proof/di_ecdsa_rdfc.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
class EcdsaRdfcProofSuite:
    """Sign and verify credentials with an embedded ``ecdsa-rdfc-2019`` proof.

    Whole-document (non-selective) ECDSA Data Integrity over RDF N-Quads —
    **P-256/SHA-256** (``ES256``) or **P-384/SHA-384** (``ES384``), selected by the
    signing/verification key's curve. The RDF sibling of
    :class:`~openvc.proof.di_jcs.EcdsaJcsProofSuite`; needs ``pyld`` (the
    ``[data-integrity]`` extra), which the JCS suite does not.
    """

    _allowed_algs = frozenset({"ES256", "ES384"})

    def __init__(self, *, leeway_s: int = DEFAULT_LEEWAY_S) -> None:
        self._leeway = leeway_s

    def _match_alg(self, jwk: dict[str, Any]) -> str:
        """The allowed alg whose (kty, crv) matches *jwk*, or fail closed (rejecting a
        cross-type key before the crypto runs — see :func:`match_alg`)."""
        return match_alg(jwk, self._allowed_algs, cryptosuite=ECDSA_RDFC_CRYPTOSUITE)

    def add_proof(
        self,
        credential: dict[str, Any],
        *,
        signing_key: SigningKey,
        verification_method: str,
        proof_purpose: str = "assertionMethod",
        challenge: str | None = None,
        domain: str | None = None,
        created: datetime | None = None,
        extra_contexts: Mapping[str, dict] | None = None,
    ) -> dict[str, Any]:
        """Return a copy of *credential* secured with an ``ecdsa-rdfc-2019`` proof.

        *verification_method* is embedded verbatim (a did:key / did:web URL a verifier
        can resolve). For a presentation proof (``proof_purpose="authentication"``) pass
        *challenge* / *domain* to bind it to a verifier session; both are covered by the
        signature. The input is not mutated. Like the ``eddsa-rdfc-2022`` suite this
        canonicalizes over RDF, so any non-bundled ``@context`` term must be supplied via
        *extra_contexts*.
        """
        if signing_key.alg not in self._allowed_algs:
            raise UnsupportedCryptosuite(
                f"{ECDSA_RDFC_CRYPTOSUITE} requires one of {sorted(self._allowed_algs)}, "
                f"got {signing_key.alg!r}")
        if "@context" not in credential:
            raise ProofMalformed("credential has no @context to canonicalize against")
        if "proof" in credential:
            raise ProofMalformed("credential already carries a proof")

        proof: dict[str, Any] = {
            "type": PROOF_TYPE,
            "cryptosuite": ECDSA_RDFC_CRYPTOSUITE,
            "created": _iso(created if created is not None else datetime.now(timezone.utc)),
            "verificationMethod": verification_method,
            "proofPurpose": proof_purpose,
        }
        if challenge is not None:
            proof["challenge"] = challenge
        if domain is not None:
            proof["domain"] = domain
        proof_config = dict(proof)
        proof_config["@context"] = credential["@context"]

        loader = document_loader(extra_contexts)
        hash_name = ALG_PROFILE[signing_key.alg][2]
        data = _hash_data(_unsecured(credential), proof_config, loader, hash_name)
        signature = signing_key.sign(data)           # raw ES256 / ES384 R‖S

        secured = copy.deepcopy(credential)
        secured["proof"] = dict(proof, proofValue=encode_multibase(signature))
        return secured

    def verify(
        self,
        secured: dict[str, Any],
        *,
        public_key_jwk: dict[str, Any] | None = None,
        resolver: Any = None,
        expected_proof_purpose: str | None = "assertionMethod",
        expected_challenge: str | None = None,
        expected_domain: str | None = None,
        now: datetime | None = None,
        extra_contexts: Mapping[str, dict] | None = None,
    ) -> VerifiedDataIntegrity:
        """Verify the embedded ``ecdsa-rdfc-2019`` proof end to end.

        Key selection, proof-purpose authorization, presentation binding and the
        validity window behave exactly as
        :meth:`~openvc.proof.data_integrity.DataIntegrityProofSuite.verify`; only the
        signature algebra (ECDSA, curve chosen by the resolved key) differs. The
        curve-selected digest and the accepted JWK ``kty``/``crv`` follow from the key
        via ``_match_alg``, so a cross-type key fails closed before the crypto runs.
        """
        proof, proof_config, signature = prepare_di_proof(
            secured, proof_type=PROOF_TYPE, cryptosuite=ECDSA_RDFC_CRYPTOSUITE)

        unsecured = _unsecured(secured)
        jwk = public_key_jwk or resolve_verification_key(
            proof.get("verificationMethod"),
            proof_purpose=proof.get("proofPurpose"),
            resolver=resolver,
        )
        # The resolved key's (kty, crv) picks the alg + digest — and rejects a cross-type
        # key before verify_signature would read a wrong JWK member and crash.
        alg = self._match_alg(jwk)
        # Build the RDF document loader only now: a cross-type or unresolvable key fails
        # above without paying the loader's context-file reads (the canonicalization in
        # _hash_data is the costly step, and it runs only on a key that matched).
        loader = document_loader(extra_contexts)
        data = _hash_data(unsecured, proof_config, loader, ALG_PROFILE[alg][2])
        try:
            ok = verify_signature(
                alg=alg, public_jwk=jwk, signing_input=data, signature=signature)
        except (OpenvcError, ValueError, KeyError) as exc:   # e.g. wrong-length R‖S, bad key
            raise SignatureInvalid(
                f"{ECDSA_RDFC_CRYPTOSUITE} proof does not verify: {exc}") from exc
        if not ok:
            raise SignatureInvalid(f"{ECDSA_RDFC_CRYPTOSUITE} proof does not verify")

        check_proof_purpose(proof, expected_proof_purpose)
        check_presentation_binding(
            proof, expected_challenge=expected_challenge, expected_domain=expected_domain)
        check_validity_window(unsecured, proof, now=now, leeway_s=self._leeway)

        issuer = secured.get("issuer")
        issuer = issuer.get("id") if isinstance(issuer, dict) else issuer
        subject = (secured.get("credentialSubject") or {})
        subject_id = subject.get("id") if isinstance(subject, dict) else None
        return VerifiedDataIntegrity(
            credential=secured, issuer=issuer, subject=subject_id, proof=proof)

add_proof(credential, *, signing_key, verification_method, proof_purpose='assertionMethod', challenge=None, domain=None, created=None, extra_contexts=None)

Return a copy of credential secured with an ecdsa-rdfc-2019 proof.

verification_method is embedded verbatim (a did:key / did:web URL a verifier can resolve). For a presentation proof (proof_purpose="authentication") pass challenge / domain to bind it to a verifier session; both are covered by the signature. The input is not mutated. Like the eddsa-rdfc-2022 suite this canonicalizes over RDF, so any non-bundled @context term must be supplied via extra_contexts.

Source code in src/openvc/proof/di_ecdsa_rdfc.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def add_proof(
    self,
    credential: dict[str, Any],
    *,
    signing_key: SigningKey,
    verification_method: str,
    proof_purpose: str = "assertionMethod",
    challenge: str | None = None,
    domain: str | None = None,
    created: datetime | None = None,
    extra_contexts: Mapping[str, dict] | None = None,
) -> dict[str, Any]:
    """Return a copy of *credential* secured with an ``ecdsa-rdfc-2019`` proof.

    *verification_method* is embedded verbatim (a did:key / did:web URL a verifier
    can resolve). For a presentation proof (``proof_purpose="authentication"``) pass
    *challenge* / *domain* to bind it to a verifier session; both are covered by the
    signature. The input is not mutated. Like the ``eddsa-rdfc-2022`` suite this
    canonicalizes over RDF, so any non-bundled ``@context`` term must be supplied via
    *extra_contexts*.
    """
    if signing_key.alg not in self._allowed_algs:
        raise UnsupportedCryptosuite(
            f"{ECDSA_RDFC_CRYPTOSUITE} requires one of {sorted(self._allowed_algs)}, "
            f"got {signing_key.alg!r}")
    if "@context" not in credential:
        raise ProofMalformed("credential has no @context to canonicalize against")
    if "proof" in credential:
        raise ProofMalformed("credential already carries a proof")

    proof: dict[str, Any] = {
        "type": PROOF_TYPE,
        "cryptosuite": ECDSA_RDFC_CRYPTOSUITE,
        "created": _iso(created if created is not None else datetime.now(timezone.utc)),
        "verificationMethod": verification_method,
        "proofPurpose": proof_purpose,
    }
    if challenge is not None:
        proof["challenge"] = challenge
    if domain is not None:
        proof["domain"] = domain
    proof_config = dict(proof)
    proof_config["@context"] = credential["@context"]

    loader = document_loader(extra_contexts)
    hash_name = ALG_PROFILE[signing_key.alg][2]
    data = _hash_data(_unsecured(credential), proof_config, loader, hash_name)
    signature = signing_key.sign(data)           # raw ES256 / ES384 R‖S

    secured = copy.deepcopy(credential)
    secured["proof"] = dict(proof, proofValue=encode_multibase(signature))
    return secured

verify(secured, *, public_key_jwk=None, resolver=None, expected_proof_purpose='assertionMethod', expected_challenge=None, expected_domain=None, now=None, extra_contexts=None)

Verify the embedded ecdsa-rdfc-2019 proof end to end.

Key selection, proof-purpose authorization, presentation binding and the validity window behave exactly as :meth:~openvc.proof.data_integrity.DataIntegrityProofSuite.verify; only the signature algebra (ECDSA, curve chosen by the resolved key) differs. The curve-selected digest and the accepted JWK kty/crv follow from the key via _match_alg, so a cross-type key fails closed before the crypto runs.

Source code in src/openvc/proof/di_ecdsa_rdfc.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def verify(
    self,
    secured: dict[str, Any],
    *,
    public_key_jwk: dict[str, Any] | None = None,
    resolver: Any = None,
    expected_proof_purpose: str | None = "assertionMethod",
    expected_challenge: str | None = None,
    expected_domain: str | None = None,
    now: datetime | None = None,
    extra_contexts: Mapping[str, dict] | None = None,
) -> VerifiedDataIntegrity:
    """Verify the embedded ``ecdsa-rdfc-2019`` proof end to end.

    Key selection, proof-purpose authorization, presentation binding and the
    validity window behave exactly as
    :meth:`~openvc.proof.data_integrity.DataIntegrityProofSuite.verify`; only the
    signature algebra (ECDSA, curve chosen by the resolved key) differs. The
    curve-selected digest and the accepted JWK ``kty``/``crv`` follow from the key
    via ``_match_alg``, so a cross-type key fails closed before the crypto runs.
    """
    proof, proof_config, signature = prepare_di_proof(
        secured, proof_type=PROOF_TYPE, cryptosuite=ECDSA_RDFC_CRYPTOSUITE)

    unsecured = _unsecured(secured)
    jwk = public_key_jwk or resolve_verification_key(
        proof.get("verificationMethod"),
        proof_purpose=proof.get("proofPurpose"),
        resolver=resolver,
    )
    # The resolved key's (kty, crv) picks the alg + digest — and rejects a cross-type
    # key before verify_signature would read a wrong JWK member and crash.
    alg = self._match_alg(jwk)
    # Build the RDF document loader only now: a cross-type or unresolvable key fails
    # above without paying the loader's context-file reads (the canonicalization in
    # _hash_data is the costly step, and it runs only on a key that matched).
    loader = document_loader(extra_contexts)
    data = _hash_data(unsecured, proof_config, loader, ALG_PROFILE[alg][2])
    try:
        ok = verify_signature(
            alg=alg, public_jwk=jwk, signing_input=data, signature=signature)
    except (OpenvcError, ValueError, KeyError) as exc:   # e.g. wrong-length R‖S, bad key
        raise SignatureInvalid(
            f"{ECDSA_RDFC_CRYPTOSUITE} proof does not verify: {exc}") from exc
    if not ok:
        raise SignatureInvalid(f"{ECDSA_RDFC_CRYPTOSUITE} proof does not verify")

    check_proof_purpose(proof, expected_proof_purpose)
    check_presentation_binding(
        proof, expected_challenge=expected_challenge, expected_domain=expected_domain)
    check_validity_window(unsecured, proof, now=now, leeway_s=self._leeway)

    issuer = secured.get("issuer")
    issuer = issuer.get("id") if isinstance(issuer, dict) else issuer
    subject = (secured.get("credentialSubject") or {})
    subject_id = subject.get("id") if isinstance(subject, dict) else None
    return VerifiedDataIntegrity(
        credential=secured, issuer=issuer, subject=subject_id, proof=proof)

Data Integrity — ecdsa-sd-2023 (selective disclosure)

openvc.proof.ecdsa_sd

openvc.proof.ecdsa_sd — Data Integrity cryptosuite ecdsa-sd-2023 (W3C VC Data Integrity ECDSA Cryptosuites): selective disclosure over P-256.

The third Data Integrity cryptosuite alongside eddsa-rdfc-2022. Where that one signs the whole canonical document once, ecdsa-sd-2023 lets an issuer sign each statement so a holder can later reveal only a chosen subset:

issuer  -> add_base_proof(doc, mandatoryPointers)     (a base proof)
holder  -> derive_proof(base, selectivePointers)      (a derived proof)
verifier-> verify(derived)                            (checks the disclosed subset)

The proof value is a multibase (u) CBOR blob. Base proof: 0xd95d00 ‖ CBOR([baseSignature, publicKey, hmacKey, signatures, mandatoryPointers]); derived proof: 0xd95d01 ‖ CBOR([baseSignature, publicKey, signatures, compressedLabelMap, mandatoryIndexes]). Blank-node labels are blinded with HMAC-SHA256; per-statement signatures use an ephemeral proof-scoped P-256 key.

Status: interop-validated against the official W3C vc-di-ecdsa test vectors — verify accepts reference-produced derived proofs, and the issuer-side canonical N-Quads and proofHash / mandatoryHash match the recorded intermediates byte for byte (tests/fixtures/ecdsa_sd/). ECDSA signatures are randomised, so — unlike eddsa-rdfc-2022 — interop is shown this way rather than by reproducing a fixed proof value.

This module reuses the P-256 backend and the SSRF-safe offline canonicalization of the other suites; it needs the [data-integrity] extra (pyld). CBOR is a small hand-rolled codec for the fixed proof-value shape (no new dependency), like the project's own varint/base58.

CredentialExpired

Bases: ProofError

The credential's validity window has ended (validUntil / expirationDate / proof expires).

Source code in src/openvc/proof/errors.py
53
54
class CredentialExpired(ProofError):
    """The credential's validity window has ended (validUntil / expirationDate / proof expires)."""

CredentialNotYetValid

Bases: ProofError

The credential's validity window has not started (validFrom / issuanceDate).

Source code in src/openvc/proof/errors.py
57
58
class CredentialNotYetValid(ProofError):
    """The credential's validity window has not started (validFrom / issuanceDate)."""

KeyResolutionError

Bases: ProofError

The proof's verificationMethod key could not be resolved.

Source code in src/openvc/proof/errors.py
69
70
class KeyResolutionError(ProofError):
    """The proof's verificationMethod key could not be resolved."""

MalformedTimestamp

Bases: ProofError

A validity timestamp is present but not a parseable date-time (fails closed).

Source code in src/openvc/proof/errors.py
61
62
class MalformedTimestamp(ProofError):
    """A validity timestamp is present but not a parseable date-time (fails closed)."""

ProofPurposeMismatch

Bases: ProofError

The proof's proofPurpose is not the expected one (e.g. assertionMethod).

Source code in src/openvc/proof/errors.py
65
66
class ProofPurposeMismatch(ProofError):
    """The proof's proofPurpose is not the expected one (e.g. assertionMethod)."""

ProofMalformed

Bases: ProofError

The proof object is structurally invalid — missing or wrongly typed fields.

Source code in src/openvc/proof/errors.py
31
32
class ProofMalformed(ProofError):
    """The proof object is structurally invalid — missing or wrongly typed fields."""

SignatureInvalid

Bases: ProofError

A proof/signature did not verify.

Source code in src/openvc/proof/errors.py
27
28
class SignatureInvalid(ProofError):
    """A proof/signature did not verify."""

UnsupportedCryptosuite

Bases: ProofError

The proof declares a cryptosuite this suite does not implement.

Source code in src/openvc/proof/errors.py
35
36
class UnsupportedCryptosuite(ProofError):
    """The proof declares a cryptosuite this suite does not implement."""

EcdsaSdProofSuite

Issue (base), derive (holder), and verify ecdsa-sd-2023 selective-disclosure Data Integrity proofs over P-256.

Source code in src/openvc/proof/ecdsa_sd.py
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
class EcdsaSdProofSuite:
    """Issue (base), derive (holder), and verify ecdsa-sd-2023 selective-disclosure
    Data Integrity proofs over P-256."""

    def __init__(self, *, leeway_s: int = DEFAULT_LEEWAY_S) -> None:
        self._leeway = leeway_s

    def add_base_proof(
        self,
        credential: dict[str, Any],
        *,
        signing_key: SigningKey,
        verification_method: str,
        mandatory_pointers: Iterable[str],
        proof_purpose: str = "assertionMethod",
        created: datetime | None = None,
        extra_contexts: Mapping[str, dict] | None = None,
    ) -> dict[str, Any]:
        """Issuer side: sign each statement so a holder can later disclose a subset.
        *mandatory_pointers* are always revealed. Needs an ES256 (P-256) key.

        Security note: the verifier's validity-window check can only see what the
        holder discloses. If this credential carries ``validFrom`` / ``validUntil``
        (or ``issuanceDate`` / ``expirationDate``) and you want a verifier to
        enforce them, include those pointers here — otherwise a holder may withhold
        the window from the derived proof and expiry goes unchecked. The W3C
        reference vectors mark the validity window mandatory for this reason."""
        if signing_key.alg != "ES256":
            raise UnsupportedCryptosuite(
                f"ecdsa-sd-2023 requires an ES256 (P-256) key, got {signing_key.alg!r}")
        if "@context" not in credential:
            raise ProofMalformed("credential has no @context to canonicalize against")
        if "proof" in credential:
            raise ProofMalformed("credential already carries a proof")

        mandatory = list(mandatory_pointers)
        loader = document_loader(extra_contexts)
        proof = {
            "type": PROOF_TYPE, "cryptosuite": CRYPTOSUITE,
            "created": _iso(created if created is not None else datetime.now(timezone.utc)),
            "verificationMethod": verification_method, "proofPurpose": proof_purpose,
        }
        proof_hash = _proof_config_hash(proof, credential["@context"], loader)

        hmac_key = secrets.token_bytes(32)
        transform = _transform(credential, hmac_key, loader)
        mandatory_set = _selection_lines(
            select_json_ld(mandatory, transform.skolemized_compact) or {},
            transform.skolem_to_hmac, loader) if mandatory else set()

        mandatory_lines = [ln for ln in transform.relabeled if ln in mandatory_set]
        non_mandatory = [ln for ln in transform.relabeled if ln not in mandatory_set]
        mandatory_hash = _sha256_lines(mandatory_lines)

        ephemeral = P256SigningKey.generate(kid="urn:ephemeral")
        public_key = p256_public_multikey(ephemeral.public_jwk())
        signatures = [ephemeral.sign((ln + "\n").encode("utf-8")) for ln in non_mandatory]
        base_signature = signing_key.sign(proof_hash + public_key + mandatory_hash)

        proof_value = encode_base_proof(
            base_signature=base_signature, public_key=public_key, hmac_key=hmac_key,
            signatures=signatures, mandatory_pointers=mandatory)
        secured = copy.deepcopy(credential)
        secured["proof"] = dict(proof, proofValue=proof_value)
        return secured

    def derive_proof(
        self,
        secured: dict[str, Any],
        *,
        selective_pointers: Iterable[str],
        extra_contexts: Mapping[str, dict] | None = None,
    ) -> dict[str, Any]:
        """Holder side: reveal only the mandatory + *selective_pointers* statements."""
        proof = secured.get("proof")
        if not isinstance(proof, dict) or proof.get("cryptosuite") != CRYPTOSUITE:
            raise UnsupportedCryptosuite("not an ecdsa-sd-2023 base proof")
        base = decode_base_proof(proof["proofValue"])
        hmac_key = base["hmac_key"]
        mandatory = list(base["mandatory_pointers"])
        combined = mandatory + [p for p in selective_pointers if p not in mandatory]

        loader = document_loader(extra_contexts)
        unsecured = {k: v for k, v in secured.items() if k != "proof"}
        transform = _transform(unsecured, hmac_key, loader)

        mandatory_set = _selection_lines(
            select_json_ld(mandatory, transform.skolemized_compact) or {},
            transform.skolem_to_hmac, loader) if mandatory else set()
        combined_selection = select_json_ld(combined, transform.skolemized_compact) or {}

        # Base non-mandatory line -> its signature (base sig order = non-mandatory order).
        non_mandatory = [ln for ln in transform.relabeled if ln not in mandatory_set]
        sig_by_line = dict(zip(non_mandatory, base["signatures"]))

        # Reveal document + the verifier's label map, computed exactly as verify will.
        reveal = _deskolemize_document(combined_selection)
        reveal_relabeled, reveal_map, reveal_mandatory_idx = self._reveal_view(
            reveal, transform.skolem_to_hmac, combined_selection, mandatory_set, loader)

        filtered_sigs: list[bytes] = []
        for i, ln in enumerate(reveal_relabeled):
            if i in reveal_mandatory_idx:
                continue
            if ln not in sig_by_line:
                raise EcdsaSdError("a disclosed statement has no matching base signature")
            filtered_sigs.append(sig_by_line[ln])

        proof_value = encode_derived_proof(
            base_signature=base["base_signature"], public_key=base["public_key"],
            signatures=filtered_sigs, label_map=reveal_map,
            mandatory_indexes=list(reveal_mandatory_idx))
        derived_proof = {k: v for k, v in proof.items() if k != "proofValue"}
        derived_proof["proofValue"] = proof_value
        reveal["proof"] = derived_proof
        return reveal

    def _reveal_view(
        self, reveal: dict[str, Any], skolem_to_hmac: dict[str, str],
        combined_selection: dict[str, Any], mandatory_set: set[str], loader: Any,
    ) -> tuple[list[str], dict[str, str], list[int]]:
        """The HMAC-relabeled reveal N-Quads (as the verifier will canonicalize them),
        the label map {c14nN -> u<b64>}, and the mandatory indexes."""
        c14n_lines, id_map = _canonicalize(_deskolemize_nquads(_to_nquads(reveal, loader)))
        # Chain reveal-c14n -> skolem -> HMAC label, keyed by the reveal's own bnodes.
        relabel_map = {c14n: skolem_to_hmac[bn] for bn, c14n in id_map.items()}
        label_map = {c14n[len("_:"):]: relabel_map[c14n][len("_:"):] for c14n in relabel_map}
        relabeled = _relabel(c14n_lines, relabel_map)
        mandatory_idx = [i for i, ln in enumerate(relabeled) if ln in mandatory_set]
        return relabeled, label_map, mandatory_idx

    def verify(
        self,
        derived: dict[str, Any],
        *,
        public_key_jwk: dict[str, Any] | None = None,
        resolver: Any = None,
        expected_proof_purpose: str | None = "assertionMethod",
        now: datetime | None = None,
        extra_contexts: Mapping[str, dict] | None = None,
    ) -> VerifiedSdCredential:
        """Verify a derived proof: the issuer's base signature over the mandatory
        statements, and the per-statement signatures over each disclosed one.

        Key selection and policy match :meth:`DataIntegrityProofSuite.verify`:
        *public_key_jwk* pins an operator-trusted key, else the P-256
        ``verificationMethod`` resolves via *resolver* (falling back to offline
        ``did:key``) and must be authorized for *expected_proof_purpose*. After
        the signatures verify, the proof's ``proofPurpose`` and the disclosed
        credential's validity window (with the suite's leeway, evaluated at *now*)
        are enforced — a derived proof only reveals a subset, so these bounds are
        checked against whatever the holder disclosed."""
        proof = derived.get("proof")
        if not isinstance(proof, dict):
            raise ProofMalformed("credential has no proof object")
        if proof.get("cryptosuite") != CRYPTOSUITE:
            raise UnsupportedCryptosuite(f"unsupported cryptosuite {proof.get('cryptosuite')!r}")
        proof_value = proof.get("proofValue")
        if not isinstance(proof_value, str):
            raise ProofValueMalformed("proofValue is missing or not a string")
        try:
            parsed = decode_derived_proof(proof_value)
        except ProofError:
            raise
        except (ValueError, KeyError, TypeError, IndexError) as exc:
            raise ProofValueMalformed(f"malformed derived proof value: {exc}") from exc

        loader = document_loader(extra_contexts)
        try:
            unsecured = {k: v for k, v in derived.items() if k != "proof"}
            c14n_lines, _ = _canonicalize(_deskolemize_nquads(_to_nquads(unsecured, loader)))
            relabel_map = {"_:" + k: "_:" + v for k, v in parsed["label_map"].items()}
            relabeled = _relabel(c14n_lines, relabel_map)

            mandatory_idx = set(parsed["mandatory_indexes"])
            mandatory_lines = [ln for i, ln in enumerate(relabeled) if i in mandatory_idx]
            non_mandatory = [ln for i, ln in enumerate(relabeled) if i not in mandatory_idx]

            proof_hash = _proof_config_hash(proof, derived.get("@context"), loader)
        except ProofError:
            raise
        except Exception as exc:
            # pyld raises its own JsonLdError (e.g. an unknown @context) alongside the
            # usual value/type errors — none is an OpenvcError. Surface a typed
            # ProofMalformed so a hostile derived credential never escapes the family.
            raise ProofMalformed(
                f"could not canonicalize the derived credential: {exc}") from exc
        mandatory_hash = _sha256_lines(mandatory_lines)
        to_verify = proof_hash + parsed["public_key"] + mandatory_hash

        issuer_jwk = public_key_jwk or resolve_verification_key(
            proof.get("verificationMethod"),
            proof_purpose=proof.get("proofPurpose"),
            resolver=resolver,
        )
        if not verify_signature(alg="ES256", public_jwk=issuer_jwk,
                                signing_input=to_verify, signature=parsed["base_signature"]):
            raise SignatureInvalid("base signature does not verify")

        ephemeral_jwk = p256_multikey_to_jwk(parsed["public_key"])
        if len(parsed["signatures"]) != len(non_mandatory):
            raise SignatureInvalid("disclosed statement / signature count mismatch")
        for line, sig in zip(non_mandatory, parsed["signatures"]):
            if not verify_signature(alg="ES256", public_jwk=ephemeral_jwk,
                                    signing_input=(line + "\n").encode("utf-8"), signature=sig):
                raise SignatureInvalid("a disclosed statement signature does not verify")

        check_proof_purpose(proof, expected_proof_purpose)
        check_validity_window(unsecured, proof, now=now, leeway_s=self._leeway)

        issuer = unsecured.get("issuer")
        issuer = issuer.get("id") if isinstance(issuer, dict) else issuer
        subj = unsecured.get("credentialSubject")
        subject = subj.get("id") if isinstance(subj, dict) else None
        return VerifiedSdCredential(
            credential=derived, issuer=issuer, subject=subject, proof=proof)

add_base_proof(credential, *, signing_key, verification_method, mandatory_pointers, proof_purpose='assertionMethod', created=None, extra_contexts=None)

Issuer side: sign each statement so a holder can later disclose a subset. mandatory_pointers are always revealed. Needs an ES256 (P-256) key.

Security note: the verifier's validity-window check can only see what the holder discloses. If this credential carries validFrom / validUntil (or issuanceDate / expirationDate) and you want a verifier to enforce them, include those pointers here — otherwise a holder may withhold the window from the derived proof and expiry goes unchecked. The W3C reference vectors mark the validity window mandatory for this reason.

Source code in src/openvc/proof/ecdsa_sd.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
def add_base_proof(
    self,
    credential: dict[str, Any],
    *,
    signing_key: SigningKey,
    verification_method: str,
    mandatory_pointers: Iterable[str],
    proof_purpose: str = "assertionMethod",
    created: datetime | None = None,
    extra_contexts: Mapping[str, dict] | None = None,
) -> dict[str, Any]:
    """Issuer side: sign each statement so a holder can later disclose a subset.
    *mandatory_pointers* are always revealed. Needs an ES256 (P-256) key.

    Security note: the verifier's validity-window check can only see what the
    holder discloses. If this credential carries ``validFrom`` / ``validUntil``
    (or ``issuanceDate`` / ``expirationDate``) and you want a verifier to
    enforce them, include those pointers here — otherwise a holder may withhold
    the window from the derived proof and expiry goes unchecked. The W3C
    reference vectors mark the validity window mandatory for this reason."""
    if signing_key.alg != "ES256":
        raise UnsupportedCryptosuite(
            f"ecdsa-sd-2023 requires an ES256 (P-256) key, got {signing_key.alg!r}")
    if "@context" not in credential:
        raise ProofMalformed("credential has no @context to canonicalize against")
    if "proof" in credential:
        raise ProofMalformed("credential already carries a proof")

    mandatory = list(mandatory_pointers)
    loader = document_loader(extra_contexts)
    proof = {
        "type": PROOF_TYPE, "cryptosuite": CRYPTOSUITE,
        "created": _iso(created if created is not None else datetime.now(timezone.utc)),
        "verificationMethod": verification_method, "proofPurpose": proof_purpose,
    }
    proof_hash = _proof_config_hash(proof, credential["@context"], loader)

    hmac_key = secrets.token_bytes(32)
    transform = _transform(credential, hmac_key, loader)
    mandatory_set = _selection_lines(
        select_json_ld(mandatory, transform.skolemized_compact) or {},
        transform.skolem_to_hmac, loader) if mandatory else set()

    mandatory_lines = [ln for ln in transform.relabeled if ln in mandatory_set]
    non_mandatory = [ln for ln in transform.relabeled if ln not in mandatory_set]
    mandatory_hash = _sha256_lines(mandatory_lines)

    ephemeral = P256SigningKey.generate(kid="urn:ephemeral")
    public_key = p256_public_multikey(ephemeral.public_jwk())
    signatures = [ephemeral.sign((ln + "\n").encode("utf-8")) for ln in non_mandatory]
    base_signature = signing_key.sign(proof_hash + public_key + mandatory_hash)

    proof_value = encode_base_proof(
        base_signature=base_signature, public_key=public_key, hmac_key=hmac_key,
        signatures=signatures, mandatory_pointers=mandatory)
    secured = copy.deepcopy(credential)
    secured["proof"] = dict(proof, proofValue=proof_value)
    return secured

derive_proof(secured, *, selective_pointers, extra_contexts=None)

Holder side: reveal only the mandatory + selective_pointers statements.

Source code in src/openvc/proof/ecdsa_sd.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
def derive_proof(
    self,
    secured: dict[str, Any],
    *,
    selective_pointers: Iterable[str],
    extra_contexts: Mapping[str, dict] | None = None,
) -> dict[str, Any]:
    """Holder side: reveal only the mandatory + *selective_pointers* statements."""
    proof = secured.get("proof")
    if not isinstance(proof, dict) or proof.get("cryptosuite") != CRYPTOSUITE:
        raise UnsupportedCryptosuite("not an ecdsa-sd-2023 base proof")
    base = decode_base_proof(proof["proofValue"])
    hmac_key = base["hmac_key"]
    mandatory = list(base["mandatory_pointers"])
    combined = mandatory + [p for p in selective_pointers if p not in mandatory]

    loader = document_loader(extra_contexts)
    unsecured = {k: v for k, v in secured.items() if k != "proof"}
    transform = _transform(unsecured, hmac_key, loader)

    mandatory_set = _selection_lines(
        select_json_ld(mandatory, transform.skolemized_compact) or {},
        transform.skolem_to_hmac, loader) if mandatory else set()
    combined_selection = select_json_ld(combined, transform.skolemized_compact) or {}

    # Base non-mandatory line -> its signature (base sig order = non-mandatory order).
    non_mandatory = [ln for ln in transform.relabeled if ln not in mandatory_set]
    sig_by_line = dict(zip(non_mandatory, base["signatures"]))

    # Reveal document + the verifier's label map, computed exactly as verify will.
    reveal = _deskolemize_document(combined_selection)
    reveal_relabeled, reveal_map, reveal_mandatory_idx = self._reveal_view(
        reveal, transform.skolem_to_hmac, combined_selection, mandatory_set, loader)

    filtered_sigs: list[bytes] = []
    for i, ln in enumerate(reveal_relabeled):
        if i in reveal_mandatory_idx:
            continue
        if ln not in sig_by_line:
            raise EcdsaSdError("a disclosed statement has no matching base signature")
        filtered_sigs.append(sig_by_line[ln])

    proof_value = encode_derived_proof(
        base_signature=base["base_signature"], public_key=base["public_key"],
        signatures=filtered_sigs, label_map=reveal_map,
        mandatory_indexes=list(reveal_mandatory_idx))
    derived_proof = {k: v for k, v in proof.items() if k != "proofValue"}
    derived_proof["proofValue"] = proof_value
    reveal["proof"] = derived_proof
    return reveal

verify(derived, *, public_key_jwk=None, resolver=None, expected_proof_purpose='assertionMethod', now=None, extra_contexts=None)

Verify a derived proof: the issuer's base signature over the mandatory statements, and the per-statement signatures over each disclosed one.

Key selection and policy match :meth:DataIntegrityProofSuite.verify: public_key_jwk pins an operator-trusted key, else the P-256 verificationMethod resolves via resolver (falling back to offline did:key) and must be authorized for expected_proof_purpose. After the signatures verify, the proof's proofPurpose and the disclosed credential's validity window (with the suite's leeway, evaluated at now) are enforced — a derived proof only reveals a subset, so these bounds are checked against whatever the holder disclosed.

Source code in src/openvc/proof/ecdsa_sd.py
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
def verify(
    self,
    derived: dict[str, Any],
    *,
    public_key_jwk: dict[str, Any] | None = None,
    resolver: Any = None,
    expected_proof_purpose: str | None = "assertionMethod",
    now: datetime | None = None,
    extra_contexts: Mapping[str, dict] | None = None,
) -> VerifiedSdCredential:
    """Verify a derived proof: the issuer's base signature over the mandatory
    statements, and the per-statement signatures over each disclosed one.

    Key selection and policy match :meth:`DataIntegrityProofSuite.verify`:
    *public_key_jwk* pins an operator-trusted key, else the P-256
    ``verificationMethod`` resolves via *resolver* (falling back to offline
    ``did:key``) and must be authorized for *expected_proof_purpose*. After
    the signatures verify, the proof's ``proofPurpose`` and the disclosed
    credential's validity window (with the suite's leeway, evaluated at *now*)
    are enforced — a derived proof only reveals a subset, so these bounds are
    checked against whatever the holder disclosed."""
    proof = derived.get("proof")
    if not isinstance(proof, dict):
        raise ProofMalformed("credential has no proof object")
    if proof.get("cryptosuite") != CRYPTOSUITE:
        raise UnsupportedCryptosuite(f"unsupported cryptosuite {proof.get('cryptosuite')!r}")
    proof_value = proof.get("proofValue")
    if not isinstance(proof_value, str):
        raise ProofValueMalformed("proofValue is missing or not a string")
    try:
        parsed = decode_derived_proof(proof_value)
    except ProofError:
        raise
    except (ValueError, KeyError, TypeError, IndexError) as exc:
        raise ProofValueMalformed(f"malformed derived proof value: {exc}") from exc

    loader = document_loader(extra_contexts)
    try:
        unsecured = {k: v for k, v in derived.items() if k != "proof"}
        c14n_lines, _ = _canonicalize(_deskolemize_nquads(_to_nquads(unsecured, loader)))
        relabel_map = {"_:" + k: "_:" + v for k, v in parsed["label_map"].items()}
        relabeled = _relabel(c14n_lines, relabel_map)

        mandatory_idx = set(parsed["mandatory_indexes"])
        mandatory_lines = [ln for i, ln in enumerate(relabeled) if i in mandatory_idx]
        non_mandatory = [ln for i, ln in enumerate(relabeled) if i not in mandatory_idx]

        proof_hash = _proof_config_hash(proof, derived.get("@context"), loader)
    except ProofError:
        raise
    except Exception as exc:
        # pyld raises its own JsonLdError (e.g. an unknown @context) alongside the
        # usual value/type errors — none is an OpenvcError. Surface a typed
        # ProofMalformed so a hostile derived credential never escapes the family.
        raise ProofMalformed(
            f"could not canonicalize the derived credential: {exc}") from exc
    mandatory_hash = _sha256_lines(mandatory_lines)
    to_verify = proof_hash + parsed["public_key"] + mandatory_hash

    issuer_jwk = public_key_jwk or resolve_verification_key(
        proof.get("verificationMethod"),
        proof_purpose=proof.get("proofPurpose"),
        resolver=resolver,
    )
    if not verify_signature(alg="ES256", public_jwk=issuer_jwk,
                            signing_input=to_verify, signature=parsed["base_signature"]):
        raise SignatureInvalid("base signature does not verify")

    ephemeral_jwk = p256_multikey_to_jwk(parsed["public_key"])
    if len(parsed["signatures"]) != len(non_mandatory):
        raise SignatureInvalid("disclosed statement / signature count mismatch")
    for line, sig in zip(non_mandatory, parsed["signatures"]):
        if not verify_signature(alg="ES256", public_jwk=ephemeral_jwk,
                                signing_input=(line + "\n").encode("utf-8"), signature=sig):
            raise SignatureInvalid("a disclosed statement signature does not verify")

    check_proof_purpose(proof, expected_proof_purpose)
    check_validity_window(unsecured, proof, now=now, leeway_s=self._leeway)

    issuer = unsecured.get("issuer")
    issuer = issuer.get("id") if isinstance(issuer, dict) else issuer
    subj = unsecured.get("credentialSubject")
    subject = subj.get("id") if isinstance(subj, dict) else None
    return VerifiedSdCredential(
        credential=derived, issuer=issuer, subject=subject, proof=proof)

encode_cbor(obj)

Encode obj as the deterministic CBOR subset the proof value uses. Rejects anything outside that subset (bool / negative / tag / null / …) with :class:EcdsaSdError — the shared :mod:openvc.cbor codec is more permissive.

Source code in src/openvc/proof/ecdsa_sd.py
124
125
126
127
128
129
130
131
132
def encode_cbor(obj: Any) -> bytes:
    """Encode *obj* as the deterministic CBOR subset the proof value uses. Rejects
    anything outside that subset (bool / negative / tag / null / …) with
    :class:`EcdsaSdError` — the shared :mod:`openvc.cbor` codec is more permissive."""
    _check_proof_subset(obj, EcdsaSdError)
    try:
        return _cbor_encode(obj)
    except CborError as exc:
        raise EcdsaSdError(str(exc)) from exc

decode_cbor(data)

Decode a proof-value CBOR item; raises :class:ProofValueMalformed on malformed bytes or on any value outside the ecdsa-sd subset (a tag, bool, null, or negative).

Source code in src/openvc/proof/ecdsa_sd.py
135
136
137
138
139
140
141
142
143
def decode_cbor(data: bytes) -> Any:
    """Decode a proof-value CBOR item; raises :class:`ProofValueMalformed` on malformed
    bytes or on any value outside the ecdsa-sd subset (a tag, bool, null, or negative)."""
    try:
        obj = _cbor_decode(data)
    except CborError as exc:
        raise ProofValueMalformed(str(exc)) from exc
    _check_proof_subset(obj, ProofValueMalformed)
    return obj

p256_public_multikey(jwk)

A P-256 public JWK -> 35-byte multikey (0x8024 ‖ 33-byte compressed point).

Source code in src/openvc/proof/ecdsa_sd.py
150
151
152
153
154
155
156
157
158
159
160
161
def p256_public_multikey(jwk: dict[str, Any]) -> bytes:
    """A P-256 public JWK -> 35-byte multikey (0x8024 ‖ 33-byte compressed point)."""
    from cryptography.hazmat.primitives.asymmetric import ec
    if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
        raise EcdsaSdError("ephemeral key is not a P-256 EC JWK")
    x = int.from_bytes(_b64url_decode(jwk["x"]), "big")
    y = int.from_bytes(_b64url_decode(jwk["y"]), "big")
    pub = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
    from cryptography.hazmat.primitives import serialization
    compressed = pub.public_bytes(
        serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint)
    return _P256_MULTIKEY_PREFIX + compressed

p256_multikey_to_jwk(multikey)

A 35-byte P-256 multikey -> public JWK.

Source code in src/openvc/proof/ecdsa_sd.py
164
165
166
167
168
169
170
171
172
173
174
def p256_multikey_to_jwk(multikey: bytes) -> dict[str, Any]:
    """A 35-byte P-256 multikey -> public JWK."""
    from cryptography.hazmat.primitives.asymmetric import ec
    if not multikey.startswith(_P256_MULTIKEY_PREFIX) or len(multikey) != 35:
        raise ProofValueMalformed("not a 35-byte P-256 multikey")
    pub = ec.EllipticCurvePublicKey.from_encoded_point(
        ec.SECP256R1(), multikey[len(_P256_MULTIKEY_PREFIX):])
    nums = pub.public_numbers()
    return {"kty": "EC", "crv": "P-256",
            "x": _b64url_encode(nums.x.to_bytes(32, "big")),
            "y": _b64url_encode(nums.y.to_bytes(32, "big"))}

hmac_label(hmac_key, canonical_label)

The blinded label for a canonical blank-node id (e.g. c14n0): u + base64url-no-pad( HMAC-SHA256(hmac_key, utf8(label)) ).

Source code in src/openvc/proof/ecdsa_sd.py
181
182
183
184
185
def hmac_label(hmac_key: bytes, canonical_label: str) -> str:
    """The blinded label for a canonical blank-node id (e.g. ``c14n0``):
    ``u`` + base64url-no-pad( HMAC-SHA256(hmac_key, utf8(label)) )."""
    digest = hmac.new(hmac_key, canonical_label.encode("utf-8"), hashlib.sha256).digest()
    return _mb_encode(digest)

compress_label_map(label_map)

{"c14nN": "u"} -> {N: } for the derived proof.

Source code in src/openvc/proof/ecdsa_sd.py
188
189
190
191
192
193
194
195
def compress_label_map(label_map: dict[str, str]) -> dict[int, bytes]:
    """{"c14nN": "u<b64url>"} -> {N: <raw digest bytes>} for the derived proof."""
    out: dict[int, bytes] = {}
    for key, value in label_map.items():
        if not key.startswith("c14n"):
            raise EcdsaSdError(f"label map key {key!r} is not a c14n label")
        out[int(key[len("c14n"):])] = _mb_decode(value)
    return out

decompress_label_map(compressed)

{N: } -> {"c14nN": "u"} for verification.

Source code in src/openvc/proof/ecdsa_sd.py
198
199
200
def decompress_label_map(compressed: dict[int, bytes]) -> dict[str, str]:
    """{N: <raw digest bytes>} -> {"c14nN": "u<b64url>"} for verification."""
    return {f"c14n{index}": _mb_encode(digest) for index, digest in compressed.items()}

encode_base_proof(*, base_signature, public_key, hmac_key, signatures, mandatory_pointers)

Serialise an ecdsa-sd-2023 base proof value (issuer side).

Source code in src/openvc/proof/ecdsa_sd.py
207
208
209
210
211
212
213
214
def encode_base_proof(
    *, base_signature: bytes, public_key: bytes, hmac_key: bytes,
    signatures: list[bytes], mandatory_pointers: list[str],
) -> str:
    """Serialise an ecdsa-sd-2023 base proof value (issuer side)."""
    body = encode_cbor(
        [base_signature, public_key, hmac_key, signatures, mandatory_pointers])
    return _mb_encode(BASE_PROOF_HEADER + body)

decode_base_proof(proof_value)

Parse an ecdsa-sd-2023 base proof value into its parts.

Source code in src/openvc/proof/ecdsa_sd.py
217
218
219
220
221
222
223
224
225
226
227
228
def decode_base_proof(proof_value: str) -> dict[str, Any]:
    """Parse an ecdsa-sd-2023 base proof value into its parts."""
    raw = _mb_decode(proof_value)
    if not raw.startswith(BASE_PROOF_HEADER):
        raise ProofValueMalformed("not an ecdsa-sd-2023 base proof (bad header)")
    parts = decode_cbor(raw[len(BASE_PROOF_HEADER):])
    if not isinstance(parts, list) or len(parts) != 5:
        raise ProofValueMalformed("base proof must decode to a 5-element array")
    base_signature, public_key, hmac_key, signatures, mandatory_pointers = parts
    return {"base_signature": base_signature, "public_key": public_key,
            "hmac_key": hmac_key, "signatures": signatures,
            "mandatory_pointers": mandatory_pointers}

encode_derived_proof(*, base_signature, public_key, signatures, label_map, mandatory_indexes)

Serialise an ecdsa-sd-2023 derived proof value (holder side).

Source code in src/openvc/proof/ecdsa_sd.py
231
232
233
234
235
236
237
238
239
def encode_derived_proof(
    *, base_signature: bytes, public_key: bytes, signatures: list[bytes],
    label_map: dict[str, str], mandatory_indexes: list[int],
) -> str:
    """Serialise an ecdsa-sd-2023 derived proof value (holder side)."""
    body = encode_cbor([
        base_signature, public_key, signatures,
        compress_label_map(label_map), mandatory_indexes])
    return _mb_encode(DERIVED_PROOF_HEADER + body)

decode_derived_proof(proof_value)

Parse an ecdsa-sd-2023 derived proof value into its parts.

Source code in src/openvc/proof/ecdsa_sd.py
242
243
244
245
246
247
248
249
250
251
252
253
254
def decode_derived_proof(proof_value: str) -> dict[str, Any]:
    """Parse an ecdsa-sd-2023 derived proof value into its parts."""
    raw = _mb_decode(proof_value)
    if not raw.startswith(DERIVED_PROOF_HEADER):
        raise ProofValueMalformed("not an ecdsa-sd-2023 derived proof (bad header)")
    parts = decode_cbor(raw[len(DERIVED_PROOF_HEADER):])
    if not isinstance(parts, list) or len(parts) != 5:
        raise ProofValueMalformed("derived proof must decode to a 5-element array")
    base_signature, public_key, signatures, compressed_map, mandatory_indexes = parts
    return {"base_signature": base_signature, "public_key": public_key,
            "signatures": signatures,
            "label_map": decompress_label_map(compressed_map),
            "mandatory_indexes": mandatory_indexes}

Data Integrity — eddsa-jcs-2022 / ecdsa-jcs-2019 (RFC 8785 JCS, no pyld)

openvc.proof.di_jcs

openvc.proof.di_jcs — the JCS Data Integrity cryptosuites (eddsa-jcs-2022 and ecdsa-jcs-2019).

Same Data Integrity flow as :mod:openvc.proof.data_integrity — ::

hashData = SHA-256(canonicalize(proofConfig)) ‖ SHA-256(canonicalize(unsecuredDocument))

sign it, embed the signature as a multibase proofValue — but the canonical form is RFC 8785 JCS (:mod:openvc.proof._jcs) instead of RDF N-Quads. That makes these a whole-document Data Integrity path with no pyld dependency: the JCS suites canonicalize pure-stdlib. eddsa-jcs-2022 signs Ed25519; ecdsa-jcs-2019 signs ECDSA over SHA-256 (P-256) or SHA-384 (P-384) — raw R‖S, like the JOSE path — the digest chosen by the key's curve.

The two suites share every step except the key algorithm, so they are one base class parameterised by (_cryptosuite, _alg); :class:DataIntegrityProofSuite (the RDF eddsa-rdfc-2022 path, pinned byte-for-byte to the W3C vectors) is left untouched.

EddsaJcsProofSuite

Bases: _JcsProofSuite

Data Integrity eddsa-jcs-2022: Ed25519 over RFC 8785 JCS (no pyld).

Source code in src/openvc/proof/di_jcs.py
212
213
214
215
216
class EddsaJcsProofSuite(_JcsProofSuite):
    """Data Integrity ``eddsa-jcs-2022``: Ed25519 over RFC 8785 JCS (no ``pyld``)."""

    _cryptosuite = EDDSA_JCS_CRYPTOSUITE
    _allowed_algs = frozenset({"EdDSA"})

EcdsaJcsProofSuite

Bases: _JcsProofSuite

Data Integrity ecdsa-jcs-2019: ECDSA over RFC 8785 JCS (no pyld).

Whole-document (non-selective) ECDSA Data Integrity — P-256/SHA-256 (ES256) or P-384/SHA-384 (ES384), selected by the signing/verification key's curve. Unlike its selective-disclosure sibling :mod:openvc.proof.ecdsa_sd it needs no pyld.

Source code in src/openvc/proof/di_jcs.py
219
220
221
222
223
224
225
226
227
228
class EcdsaJcsProofSuite(_JcsProofSuite):
    """Data Integrity ``ecdsa-jcs-2019``: ECDSA over RFC 8785 JCS (no ``pyld``).

    Whole-document (non-selective) ECDSA Data Integrity — **P-256/SHA-256** (ES256) or
    **P-384/SHA-384** (ES384), selected by the signing/verification key's curve. Unlike
    its selective-disclosure sibling :mod:`openvc.proof.ecdsa_sd` it needs no ``pyld``.
    """

    _cryptosuite = ECDSA_JCS_CRYPTOSUITE
    _allowed_algs = frozenset({"ES256", "ES384"})

VP-JWT — holder presentations

openvc.proof.vp_jwt

openvc.proof.vp_jwt — Verifiable Presentation in JWT form (VP-JWT).

A holder wraps one or more credentials in a vp object and signs it: this proves possession of the holder key and binds the presentation to a specific verifier (aud) and a one-time challenge (nonce) so it cannot be replayed.

holder  -> sign(credentials, holder_key, audience, nonce)   (a VP-JWT)
verifier-> verify(vp, ..., audience, nonce)                 (holder sig + aud/nonce
                                                             + every embedded VC)

Verification checks the holder signature (allow-listed {ES256, ES384, EdDSA, Ed25519}), the temporal claims, aud and nonce, then verifies each embedded credential through the generic pipeline (:func:openvc.verify.verify_credential) — so a VP is only accepted when the holder is authentic and every credential in it is.

The holder key is resolved from the untrusted iss/kid via an injected resolver (as in the pipeline), or pinned with holder_key_jwk.

VerifiedPresentation dataclass

A successfully verified VP-JWT.

Source code in src/openvc/proof/vp_jwt.py
34
35
36
37
38
39
40
@dataclass(frozen=True)
class VerifiedPresentation:
    """A successfully verified VP-JWT."""
    holder: str | None
    credentials: tuple                 # a VerificationResult per embedded credential
    claims: dict[str, Any]             # the full JWT claim set (aud, nonce, vp, ...)
    vp: dict[str, Any]                 # the `vp` object

VpJwtProofSuite

Sign and verify VP-JWT holder presentations.

Source code in src/openvc/proof/vp_jwt.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
class VpJwtProofSuite:
    """Sign and verify VP-JWT holder presentations."""

    def __init__(self, *, leeway_s: int = DEFAULT_LEEWAY_S) -> None:
        self._leeway = leeway_s

    # -- untrusted inspection --------------------------------------------- #

    def peek_holder(self, vp_jwt: str) -> tuple[str | None, str | None]:
        """Return (holder, kid) WITHOUT verifying. UNTRUSTED — use only to select
        which key to resolve."""
        header, payload, _, _ = parse_compact(vp_jwt)
        vp = payload.get("vp")
        holder = payload.get("iss") or (vp.get("holder") if isinstance(vp, dict) else None)
        return (holder if isinstance(holder, str) else None), header.get("kid")

    # -- holder presentation ---------------------------------------------- #

    def sign(
        self,
        credentials: list,
        *,
        holder_key: SigningKey,
        audience: str,
        nonce: str,
        holder: str | None = None,
        expires_in_s: int | None = None,
    ) -> str:
        """Wrap *credentials* (VC-JWT / SD-JWT strings, or Data Integrity dicts) in a
        VP-JWT signed by *holder_key*, bound to *audience* and *nonce*."""
        now = int(time.time())
        holder_id = holder or holder_key.kid.split("#", 1)[0]
        vp = {
            "@context": [_VP_CONTEXT],
            "type": ["VerifiablePresentation"],
            "verifiableCredential": list(credentials),
            "holder": holder_id,
        }
        payload: dict[str, Any] = {
            "iss": holder_id, "aud": audience, "nonce": nonce,
            "nbf": now, "iat": now, "vp": vp,
        }
        if expires_in_s is not None:
            payload["exp"] = now + expires_in_s
        header = {"alg": holder_key.alg, "typ": "JWT", "kid": holder_key.kid}
        return sign_compact(header, payload, signing_key=holder_key)

    # -- verification ------------------------------------------------------ #

    def verify(
        self,
        vp_jwt: str,
        *,
        audience: str,
        nonce: str,
        holder_key_jwk: dict[str, Any] | None = None,
        resolver: Any = None,
        expected_holder: str | None = None,
        require_holder_binding: bool = False,
        **credential_verify_kwargs: Any,
    ) -> VerifiedPresentation:
        """Verify a VP-JWT end to end: the holder signature, temporal claims,
        ``aud`` and ``nonce``, then every embedded credential via
        :func:`openvc.verify.verify_credential` (passing *resolver* and any
        *credential_verify_kwargs*, e.g. ``policy=`` / ``resolve_status_list=``).

        The holder key is *holder_key_jwk* if given, else resolved from the peeked
        ``iss``/``kid`` through *resolver*.

        **Holder binding.** By default (``require_holder_binding=False``) this proves
        the *holder* signed the presentation and each credential is *valid*, but does
        NOT require the credentials to have been issued to that holder — a holder can
        legitimately present a third party's credential. Set
        ``require_holder_binding=True`` to additionally require every embedded
        credential's ``credentialSubject.id`` to equal the holder, so a presenter
        cannot pass off a credential issued to someone else as their own. (Off by
        default to match ``SdJwtVcProofSuite``'s ``require_key_binding``.) The
        holder's identity is authenticated only in resolver mode (the key is
        resolved *from* ``iss``); with a **pinned** ``holder_key_jwk`` the ``iss``
        is signer-supplied, so *expected_holder* must be given to bind and to trust
        the returned holder. *expected_holder*, if set, requires ``iss`` to equal
        it. *audience* and *nonce* are required and must be non-empty — VP-JWT has
        no unbound mode."""
        if not audience or not nonce:
            raise ClaimsInvalid("VP-JWT verify requires a non-empty audience and nonce")

        # the resolver serves both the holder key and the cascade; default it (like
        # verify_credential) unless the holder key is pinned
        if holder_key_jwk is None and resolver is None:
            from ..verify import default_resolver
            resolver = default_resolver()

        pinned = holder_key_jwk is not None
        if holder_key_jwk is None:
            holder_key_jwk = self._resolve_holder_key(vp_jwt, resolver)
        header, claims = verify_compact(vp_jwt, public_key_jwk=holder_key_jwk)

        self._check_temporal(claims)
        self._check_audience(claims.get("aud"), audience)
        if claims.get("nonce") != nonce:
            raise ClaimsInvalid("nonce does not match the expected challenge")

        vp = claims.get("vp")
        if not isinstance(vp, dict):
            raise ClaimsInvalid("VP-JWT has no `vp` object")
        holder = claims.get("iss") or vp.get("holder")
        if not isinstance(holder, str) or not holder:
            raise ClaimsInvalid("VP-JWT has no holder (iss / vp.holder)")
        if vp.get("holder") and vp.get("holder") != holder:
            raise ClaimsInvalid("vp.holder does not match iss")
        if expected_holder is not None and holder != expected_holder:
            raise ClaimsInvalid(f"holder {holder!r} != expected {expected_holder!r}")

        # binding is sound only against an AUTHENTICATED holder: resolver mode binds
        # iss to the key; a pinned key does not, so it needs expected_holder (checked
        # up front, before the cascade even runs)
        if require_holder_binding and pinned and expected_holder is None:
            raise ClaimsInvalid(
                "require_holder_binding with a pinned holder key needs "
                "expected_holder to authenticate the presenter")

        from ..cache import batch_resolvers
        from ..verify import verify_credential
        raw = vp.get("verifiableCredential", [])
        entries = [raw] if isinstance(raw, (str, dict)) else list(raw)
        # Dedupe resolution across the embedded credentials: a VP whose 5 credentials share
        # an issuer resolves that DID once, not five times (and fetches+verifies a shared
        # status list once). The cascade stays fail-fast — a VP is valid only if *every*
        # credential is — so the first failure still propagates.
        cascade_kwargs = dict(credential_verify_kwargs)
        shared = batch_resolvers(
            resolver,
            resolve_status_list=cascade_kwargs.pop("resolve_status_list", None),
            resolve_status_list_token=cascade_kwargs.pop("resolve_status_list_token", None),
            resolve_credential_schema=cascade_kwargs.pop("resolve_credential_schema", None),
            jwt_vc_issuer_fetch=cascade_kwargs.pop("jwt_vc_issuer_fetch", None),
        )
        results = tuple(
            verify_credential(entry, **shared, **cascade_kwargs) for entry in entries)

        if require_holder_binding:
            for result in results:
                if not result.subject or result.subject != holder:
                    raise ClaimsInvalid(
                        f"credential subject {result.subject!r} is not the holder "
                        f"{holder!r} (holder binding required)")

        return VerifiedPresentation(
            holder=holder, credentials=results, claims=claims, vp=vp)

    # -- internals --------------------------------------------------------- #

    def _resolve_holder_key(self, vp_jwt: str, resolver: Any) -> dict[str, Any]:
        if resolver is None:
            raise ClaimsInvalid("VP-JWT verify needs a holder_key_jwk or a resolver")
        holder, kid = self.peek_holder(vp_jwt)
        if not holder:
            raise MalformedToken("VP-JWT has no holder (iss / vp.holder) to resolve")
        from ..verify import KeyResolutionFailed, _resolve_jose_key
        try:
            return _resolve_jose_key(resolver, holder, kid)
        except KeyResolutionFailed as exc:
            raise ClaimsInvalid(f"could not resolve holder key: {exc}") from exc

    def _check_temporal(self, claims: dict[str, Any]) -> None:
        # single-sourced with the other JOSE suites; non-finite exp/nbf fails closed
        check_jwt_temporal(claims, leeway_s=self._leeway, subject="presentation")

    @staticmethod
    def _check_audience(aud: Any, expected: str) -> None:
        ok = aud == expected or (isinstance(aud, list) and expected in aud)
        if not ok:
            raise ClaimsInvalid(f"aud {aud!r} != expected verifier {expected!r}")

peek_holder(vp_jwt)

Return (holder, kid) WITHOUT verifying. UNTRUSTED — use only to select which key to resolve.

Source code in src/openvc/proof/vp_jwt.py
51
52
53
54
55
56
57
def peek_holder(self, vp_jwt: str) -> tuple[str | None, str | None]:
    """Return (holder, kid) WITHOUT verifying. UNTRUSTED — use only to select
    which key to resolve."""
    header, payload, _, _ = parse_compact(vp_jwt)
    vp = payload.get("vp")
    holder = payload.get("iss") or (vp.get("holder") if isinstance(vp, dict) else None)
    return (holder if isinstance(holder, str) else None), header.get("kid")

sign(credentials, *, holder_key, audience, nonce, holder=None, expires_in_s=None)

Wrap credentials (VC-JWT / SD-JWT strings, or Data Integrity dicts) in a VP-JWT signed by holder_key, bound to audience and nonce.

Source code in src/openvc/proof/vp_jwt.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def sign(
    self,
    credentials: list,
    *,
    holder_key: SigningKey,
    audience: str,
    nonce: str,
    holder: str | None = None,
    expires_in_s: int | None = None,
) -> str:
    """Wrap *credentials* (VC-JWT / SD-JWT strings, or Data Integrity dicts) in a
    VP-JWT signed by *holder_key*, bound to *audience* and *nonce*."""
    now = int(time.time())
    holder_id = holder or holder_key.kid.split("#", 1)[0]
    vp = {
        "@context": [_VP_CONTEXT],
        "type": ["VerifiablePresentation"],
        "verifiableCredential": list(credentials),
        "holder": holder_id,
    }
    payload: dict[str, Any] = {
        "iss": holder_id, "aud": audience, "nonce": nonce,
        "nbf": now, "iat": now, "vp": vp,
    }
    if expires_in_s is not None:
        payload["exp"] = now + expires_in_s
    header = {"alg": holder_key.alg, "typ": "JWT", "kid": holder_key.kid}
    return sign_compact(header, payload, signing_key=holder_key)

verify(vp_jwt, *, audience, nonce, holder_key_jwk=None, resolver=None, expected_holder=None, require_holder_binding=False, **credential_verify_kwargs)

Verify a VP-JWT end to end: the holder signature, temporal claims, aud and nonce, then every embedded credential via :func:openvc.verify.verify_credential (passing resolver and any credential_verify_kwargs, e.g. policy= / resolve_status_list=).

The holder key is holder_key_jwk if given, else resolved from the peeked iss/kid through resolver.

Holder binding. By default (require_holder_binding=False) this proves the holder signed the presentation and each credential is valid, but does NOT require the credentials to have been issued to that holder — a holder can legitimately present a third party's credential. Set require_holder_binding=True to additionally require every embedded credential's credentialSubject.id to equal the holder, so a presenter cannot pass off a credential issued to someone else as their own. (Off by default to match SdJwtVcProofSuite's require_key_binding.) The holder's identity is authenticated only in resolver mode (the key is resolved from iss); with a pinned holder_key_jwk the iss is signer-supplied, so expected_holder must be given to bind and to trust the returned holder. expected_holder, if set, requires iss to equal it. audience and nonce are required and must be non-empty — VP-JWT has no unbound mode.

Source code in src/openvc/proof/vp_jwt.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def verify(
    self,
    vp_jwt: str,
    *,
    audience: str,
    nonce: str,
    holder_key_jwk: dict[str, Any] | None = None,
    resolver: Any = None,
    expected_holder: str | None = None,
    require_holder_binding: bool = False,
    **credential_verify_kwargs: Any,
) -> VerifiedPresentation:
    """Verify a VP-JWT end to end: the holder signature, temporal claims,
    ``aud`` and ``nonce``, then every embedded credential via
    :func:`openvc.verify.verify_credential` (passing *resolver* and any
    *credential_verify_kwargs*, e.g. ``policy=`` / ``resolve_status_list=``).

    The holder key is *holder_key_jwk* if given, else resolved from the peeked
    ``iss``/``kid`` through *resolver*.

    **Holder binding.** By default (``require_holder_binding=False``) this proves
    the *holder* signed the presentation and each credential is *valid*, but does
    NOT require the credentials to have been issued to that holder — a holder can
    legitimately present a third party's credential. Set
    ``require_holder_binding=True`` to additionally require every embedded
    credential's ``credentialSubject.id`` to equal the holder, so a presenter
    cannot pass off a credential issued to someone else as their own. (Off by
    default to match ``SdJwtVcProofSuite``'s ``require_key_binding``.) The
    holder's identity is authenticated only in resolver mode (the key is
    resolved *from* ``iss``); with a **pinned** ``holder_key_jwk`` the ``iss``
    is signer-supplied, so *expected_holder* must be given to bind and to trust
    the returned holder. *expected_holder*, if set, requires ``iss`` to equal
    it. *audience* and *nonce* are required and must be non-empty — VP-JWT has
    no unbound mode."""
    if not audience or not nonce:
        raise ClaimsInvalid("VP-JWT verify requires a non-empty audience and nonce")

    # the resolver serves both the holder key and the cascade; default it (like
    # verify_credential) unless the holder key is pinned
    if holder_key_jwk is None and resolver is None:
        from ..verify import default_resolver
        resolver = default_resolver()

    pinned = holder_key_jwk is not None
    if holder_key_jwk is None:
        holder_key_jwk = self._resolve_holder_key(vp_jwt, resolver)
    header, claims = verify_compact(vp_jwt, public_key_jwk=holder_key_jwk)

    self._check_temporal(claims)
    self._check_audience(claims.get("aud"), audience)
    if claims.get("nonce") != nonce:
        raise ClaimsInvalid("nonce does not match the expected challenge")

    vp = claims.get("vp")
    if not isinstance(vp, dict):
        raise ClaimsInvalid("VP-JWT has no `vp` object")
    holder = claims.get("iss") or vp.get("holder")
    if not isinstance(holder, str) or not holder:
        raise ClaimsInvalid("VP-JWT has no holder (iss / vp.holder)")
    if vp.get("holder") and vp.get("holder") != holder:
        raise ClaimsInvalid("vp.holder does not match iss")
    if expected_holder is not None and holder != expected_holder:
        raise ClaimsInvalid(f"holder {holder!r} != expected {expected_holder!r}")

    # binding is sound only against an AUTHENTICATED holder: resolver mode binds
    # iss to the key; a pinned key does not, so it needs expected_holder (checked
    # up front, before the cascade even runs)
    if require_holder_binding and pinned and expected_holder is None:
        raise ClaimsInvalid(
            "require_holder_binding with a pinned holder key needs "
            "expected_holder to authenticate the presenter")

    from ..cache import batch_resolvers
    from ..verify import verify_credential
    raw = vp.get("verifiableCredential", [])
    entries = [raw] if isinstance(raw, (str, dict)) else list(raw)
    # Dedupe resolution across the embedded credentials: a VP whose 5 credentials share
    # an issuer resolves that DID once, not five times (and fetches+verifies a shared
    # status list once). The cascade stays fail-fast — a VP is valid only if *every*
    # credential is — so the first failure still propagates.
    cascade_kwargs = dict(credential_verify_kwargs)
    shared = batch_resolvers(
        resolver,
        resolve_status_list=cascade_kwargs.pop("resolve_status_list", None),
        resolve_status_list_token=cascade_kwargs.pop("resolve_status_list_token", None),
        resolve_credential_schema=cascade_kwargs.pop("resolve_credential_schema", None),
        jwt_vc_issuer_fetch=cascade_kwargs.pop("jwt_vc_issuer_fetch", None),
    )
    results = tuple(
        verify_credential(entry, **shared, **cascade_kwargs) for entry in entries)

    if require_holder_binding:
        for result in results:
            if not result.subject or result.subject != holder:
                raise ClaimsInvalid(
                    f"credential subject {result.subject!r} is not the holder "
                    f"{holder!r} (holder binding required)")

    return VerifiedPresentation(
        holder=holder, credentials=results, claims=claims, vp=vp)