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.) Two explicit, per-suite opt-ins add names and nothing else: allow_pq=True the experimental ML-DSA set, and allow_es256k=True the single name ES256K (RFC 8812, ECDSA over secp256k1 with SHA-256), which EBSI Core API v1 uses for its RootTAO accreditations. ES256K is VERIFY-ONLY (this module never signs it) and never in :data:ALLOWED_ALGS; neither opt-in implies the other. allow_pq and allow_es256k must each be exactly True or False (a string such as "false" from a configuration file is refused, not read as true), and an ES256K JWK is held to RFC 8812 section 3.2: alg, use and key_ops, when present, must be ES256K, sig and an array including verify.
  • 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.
  • Explicit typing: a JWS typed as an SD-JWT issuer JWT or a KB-JWT, or whose payload carries an SD-JWT marker (_sd, _sd_alg, "...") at any depth, is a MalformedToken before the signature is checked (#189, #291). The markers are reserved by RFC 9901 §4.1 item 7 (_sd and ...), §4.2.4.2 (the {"...": digest} array-element object) and §4.1.1 (_sd_alg, top level only).

ClaimsInvalid

Bases: ProofError

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

Source code in src/openvc/proof/errors.py
51
52
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
47
48
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
27
28
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
31
32
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
43
44
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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@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
125
126
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
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
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
class VcJwtProofSuite:
    """VC-JWT (JOSE-secured Verifiable Credential) proof suite."""

    def __init__(
        self, *, leeway_s: int = DEFAULT_LEEWAY_S, allow_pq: bool = False,
        allow_es256k: bool = False,
    ) -> None:
        self._leeway = leeway_seconds(leeway_s)       # fail closed, up front (0..MAX_LEEWAY_S)
        # A flag that widens the allow-list is exactly True or exactly False. `if flag:` would
        # read the strings "false" and "0" (a config or environment value) as True. type() is
        # used, never isinstance(): a __class__ property fools isinstance (see _plain_int).
        # Both flags, in signature order, so the two refusals read alike.
        if type(allow_pq) is not bool:
            raise ProofError(f"allow_pq must be True or False, got {type(allow_pq).__name__}")
        if type(allow_es256k) is not bool:
            raise ProofError(
                f"allow_es256k must be True or False, got {type(allow_es256k).__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).
        # allow_es256k merges the single name ES256K (EBSI interop, ADR-0009), verification
        # only. The two flags are independent: each adds its own names and nothing else.
        algs = ALLOWED_ALGS
        if allow_pq:
            algs = algs | ALLOWED_ALGS_PQ
        if allow_es256k:
            algs = algs | ALLOWED_ALGS_ES256K
        self._algs = algs
        # What sign() may hand to sign_compact: the verify set minus ES256K, because openvc
        # has no secp256k1 signing backend and must not become one by way of a third-party
        # SigningKey whose alg happens to be "ES256K".
        self._sign_algs = algs - ALLOWED_ALGS_ES256K

    # -- 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")
        kid = header.get("kid")
        if kid is not None and not isinstance(kid, str):
            raise MalformedToken("kid must be a string")
        return iss, 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,
        check_temporal: bool = True,
    ) -> VerifiedCredential:
        """Verify signature + temporal claims + VC-JWT reconciliation.

        *check_temporal* False waives only the expiry checks (JWT ``exp``, and the
        body's ``validUntil`` / ``expirationDate``); the result's ``expired`` then
        reports whether one of them would have failed."""
        check_expected_audience(audience)          # before PyJWT sees it, on both lanes
        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")
        # allow-list BEFORE crypto; a JSON array/object `alg` is unhashable, so
        # type-check it first or `in frozenset` raises a bare TypeError
        if not isinstance(alg, str) or alg not in self._algs:
            raise UnsupportedAlgorithm(f"algorithm {safe_repr(alg)} is not permitted")
        reject_unknown_crit(header)
        _reject_sd_jwt_typ(header)
        # NumericDate shapes before either lane reads them. PyJWT int()-casts exp/nbf/iat
        # and catches only ValueError, so a signed `"nbf": null` raised a bare TypeError
        # that escaped OpenvcError and aborted verify_many; and verify_exp=False (the
        # expiry waiver) skipped exp entirely, accepting NaN/Infinity/"abc". This reads
        # the untrusted payload but can only reject: the signature still gates everything
        # accepted, and peek_claims is the same parse peek_issuer already runs pre-crypto.
        payload = self.peek_claims(token)
        _reject_sd_jwt_payload(payload)
        check_numeric_dates(payload, "exp", "nbf", "iat")

        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, jwt_expired = self._verify_mldsa(
                token, alg, public_key_jwk, audience, check_temporal=check_temporal)
        else:
            key = self._jwk_to_key(public_key_jwk, alg)
            jwt_expired = False
            # PyJWT judges `exp` in both modes, so a waived expiry is exactly the one the
            # default policy rejects: its clock, its leeway, its coercion of the value.
            # Only once it says "expired" does the waiver decode again without that one
            # check, so every other claim check still runs.
            try:
                claims = self._decode(token, key, alg, audience, verify_exp=True)
            except pyjwt.ExpiredSignatureError as exc:
                if check_temporal:
                    raise ClaimsInvalid(str(exc)) from exc
                claims = self._decode(token, key, alg, audience, verify_exp=False)
                jwt_expired = True

        # RFC 7519 §4.1.3 on both lanes (ADR-0008): a token restricted to an audience
        # verifies only for a caller that names one in it. After crypto, on signed claims.
        check_jwt_audience(claims, audience)

        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.
        body_expired = check_validity_window(
            credential, {}, now=None, leeway_s=self._leeway,
            check_temporal=check_temporal)

        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,
            expired=jwt_expired or body_expired,
        )

    def _decode(
        self, token: str, key: Any, alg: str, audience: str | None, *, verify_exp: bool,
    ) -> dict[str, Any]:
        """PyJWT decode with openvc's pinned options. Errors become typed
        ``ProofError``\\s, except ``ExpiredSignatureError``, which is left to
        :meth:`verify` to enforce or waive."""
        try:
            claims: dict[str, Any] = _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": verify_exp,
                    "verify_nbf": True,
                    "verify_aud": audience is not None,
                },
            )
        except pyjwt.ExpiredSignatureError:
            raise
        except pyjwt.InvalidSignatureError as exc:
            raise SignatureInvalid(str(exc)) from exc
        except (pyjwt.PyJWTError, OverflowError, TypeError, ValueError,
                RecursionError) as exc:
            # Backstop for PyJWT on attacker bytes. Its int() casts are stopped by
            # the shape gate in verify(). RecursionError comes from its own json.loads,
            # which catches only ValueError. On Python <= 3.11 the json depth limit
            # shrinks with stack depth, and PyJWT parses a few frames deeper than
            # peek_claims, so a payload can pass our parse and overflow PyJWT's.
            raise ClaimsInvalid(str(exc)) from exc
        return 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._sign_algs)

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

    def _verify_mldsa(
        self, token: str, alg: str, public_key_jwk: dict[str, Any], audience: str | None,
        *, check_temporal: bool = True,
    ) -> tuple[dict[str, Any], bool]:
        """Verify an ML-DSA VC-JWT: signature via the dependency-light primitive (PyJWT
        has no ML-DSA), then the JWT claims validated here. Returns the claims and
        whether a waived ``exp`` has passed."""
        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")
        expired = self._check_jwt_claims(claims, audience, check_temporal=check_temporal)
        return claims, expired

    def _check_jwt_claims(
        self, claims: dict[str, Any], audience: str | None, *,
        check_temporal: bool = True,
    ) -> bool:
        # Mirror the PyJWT gate for the ML-DSA path: require iss; exp/nbf/iat with the
        # suite leeway, a future iat included (PyJWT's verify_iat); a present sub/jti
        # must be a string (PyJWT's verify_sub/verify_jti). aud runs in verify() for
        # both lanes. Returns whether a waived exp has passed.
        if not isinstance(claims.get("iss"), str):
            raise ClaimsInvalid("the 'iss' claim is required")
        # PyJWT rejects a non-string sub / jti; so must this lane, or _reconcile would
        # compare two attacker containers with != (a bare RecursionError when deep)
        if "sub" in claims and not isinstance(claims["sub"], str):
            raise ClaimsInvalid("Subject must be a string")
        if "jti" in claims and not isinstance(claims["jti"], str):
            raise ClaimsInvalid("JWT ID must be a string")
        expired = check_jwt_temporal(
            claims, leeway_s=self._leeway, subject="token",
            check_temporal=check_temporal)
        return expired

    @staticmethod
    def _jwk_to_key(jwk: dict[str, Any], alg: str) -> Any:
        # Before any branch reads a member: a resolver or a caller that hands over a non-object
        # (None, a list, a string, a number) must meet a typed ProofError, not the bare
        # AttributeError of `.get`, which is no OpenvcError and so escapes verify_many.
        # The message names the type and never echoes the value.
        if not isinstance(jwk, dict):
            raise ProofError(f"{alg} requires a JWK object, got {type(jwk).__name__}")
        if alg == "ES256K":                  # its own loader: width, canonical form, on-curve
            return _es256k_public_key(jwk)
        # 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={safe_repr(jwk.get('kty'))} crv={safe_repr(jwk.get('crv'))}")
        # 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={safe_repr(jwk.get('kty'))} crv={safe_repr(jwk.get('crv'))}")
        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).

        Also the SD-JWT suite's rule for a VCDM payload, called with the processed claim
        set as both arguments (VC-JOSE-COSE §4.1.2 / §3.1.3)."""
        iss = claims.get("iss")
        if not isinstance(iss, str):        # decode() required "iss"; be explicit
            raise ClaimsInvalid("iss claim is missing or not a string")
        if "issuer" in vc:
            # A present issuer identifies no one but the verified iss, whatever its shape:
            # `@id` (what `id` aliases in JSON-LD) counts, and so does a null / "" / non-str
            # value a looser check would skip. An object with no id names no one, like an
            # absent issuer (the VCDM 1.1 JWT encoding moves the id to `iss`).
            vc_issuer = vc["issuer"]
            if any(name != iss for name in issuer_identifiers(vc_issuer)):
                raise ClaimsInvalid(f"iss {iss!r} != issuer {safe_repr(vc_issuer)}")

        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 {safe_repr(sub)} != credentialSubject.id {safe_repr(vc_sub)}")

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

        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:
        if isinstance(expected, str):                 # one type, not its characters
            expected = [expected]
        types = vc.get("type", [])
        if isinstance(types, str):
            types = [types]
        elif not isinstance(types, (list, tuple)):   # null/number/bool/object (whose keys
            types = []                                 # must not pass): no expected 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
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
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")
    kid = header.get("kid")
    if kid is not None and not isinstance(kid, str):
        raise MalformedToken("kid must be a string")
    return iss, 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
360
361
362
363
364
365
366
367
368
369
370
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, check_temporal=True)

Verify signature + temporal claims + VC-JWT reconciliation.

check_temporal False waives only the expiry checks (JWT exp, and the body's validUntil / expirationDate); the result's expired then reports whether one of them would have failed.

Source code in src/openvc/proof/vc_jwt.py
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
def verify(
    self,
    token: str,
    *,
    public_key_jwk: dict[str, Any],
    expected_types: list[str] | None = None,
    audience: str | None = None,
    check_temporal: bool = True,
) -> VerifiedCredential:
    """Verify signature + temporal claims + VC-JWT reconciliation.

    *check_temporal* False waives only the expiry checks (JWT ``exp``, and the
    body's ``validUntil`` / ``expirationDate``); the result's ``expired`` then
    reports whether one of them would have failed."""
    check_expected_audience(audience)          # before PyJWT sees it, on both lanes
    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")
    # allow-list BEFORE crypto; a JSON array/object `alg` is unhashable, so
    # type-check it first or `in frozenset` raises a bare TypeError
    if not isinstance(alg, str) or alg not in self._algs:
        raise UnsupportedAlgorithm(f"algorithm {safe_repr(alg)} is not permitted")
    reject_unknown_crit(header)
    _reject_sd_jwt_typ(header)
    # NumericDate shapes before either lane reads them. PyJWT int()-casts exp/nbf/iat
    # and catches only ValueError, so a signed `"nbf": null` raised a bare TypeError
    # that escaped OpenvcError and aborted verify_many; and verify_exp=False (the
    # expiry waiver) skipped exp entirely, accepting NaN/Infinity/"abc". This reads
    # the untrusted payload but can only reject: the signature still gates everything
    # accepted, and peek_claims is the same parse peek_issuer already runs pre-crypto.
    payload = self.peek_claims(token)
    _reject_sd_jwt_payload(payload)
    check_numeric_dates(payload, "exp", "nbf", "iat")

    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, jwt_expired = self._verify_mldsa(
            token, alg, public_key_jwk, audience, check_temporal=check_temporal)
    else:
        key = self._jwk_to_key(public_key_jwk, alg)
        jwt_expired = False
        # PyJWT judges `exp` in both modes, so a waived expiry is exactly the one the
        # default policy rejects: its clock, its leeway, its coercion of the value.
        # Only once it says "expired" does the waiver decode again without that one
        # check, so every other claim check still runs.
        try:
            claims = self._decode(token, key, alg, audience, verify_exp=True)
        except pyjwt.ExpiredSignatureError as exc:
            if check_temporal:
                raise ClaimsInvalid(str(exc)) from exc
            claims = self._decode(token, key, alg, audience, verify_exp=False)
            jwt_expired = True

    # RFC 7519 §4.1.3 on both lanes (ADR-0008): a token restricted to an audience
    # verifies only for a caller that names one in it. After crypto, on signed claims.
    check_jwt_audience(claims, audience)

    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.
    body_expired = check_validity_window(
        credential, {}, now=None, leeway_s=self._leeway,
        check_temporal=check_temporal)

    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,
        expired=jwt_expired or body_expired,
    )

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
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
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._sign_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-18 (the VC profile: vct, aka_vcts, cnf, status …; a draft that builds on RFC 9901)

The same suite verifies a W3C VCDM credential secured as SD-JWT (VC-JOSE-COSE §3.2: vc+sd-jwt, the draft-era vc+ld+json+sd-jwt, or no typ), accepted only as a conforming VCDM payload (#275).

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), and dates it: its iat is required and must fall within kb_max_age_s of the verifier's now (RFC 9901 §7.3 step 5.5).

Revocation: the suite itself does not check status. :func:openvc.verify.verify_credential does (an SD-JWT VC's status claim carrying an IETF Token Status List reference, or a W3C credentialStatus), fail-closed by default; called directly, 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
125
126
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
 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
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
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:
        # Added to both edges of every window here, so NaN would switch them off.
        self._leeway = leeway_seconds(leeway_s, error=SdJwtError)
        if hash_name not in _HASHES:
            raise SdJwtError(f"unsupported hash {hash_name!r}")
        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). It is
        # exactly True or exactly False: `if allow_pq:` would read the strings "false" and "0"
        # (a config or environment value) as True. type(), never isinstance(): a __class__
        # property fools isinstance. The same rule as VcJwtProofSuite's two flags (#341).
        if type(allow_pq) is not bool:
            raise SdJwtError(f"allow_pq must be True or False, got {type(allow_pq).__name__}")
        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.

        For a W3C credential (``typ`` absent, ``vc+sd-jwt`` or ``vc+ld+json+sd-jwt``) whose
        body has no ``iss`` at all, the first item is the VCDM ``issuer`` (a string, or an
        object's ``id``): VC-JOSE-COSE §4.1.2 makes ``iss`` optional there (#290). Stricter
        than the VC-JWT lane's peek, which reads ``vc.issuer`` for any falsy ``iss``: only
        when ``iss`` is ABSENT, and an object's ``id`` / ``@id`` must all agree. A
        ``dc+sd-jwt`` still needs ``iss``."""
        header, payload, _, _ = self._decode_jws(sd_jwt.split("~", 1)[0])
        iss = _issuer_from_body(payload, header)
        if not iss or not isinstance(iss, str):
            raise MalformedToken(
                "no iss in the issuer-signed JWT"
                + (_NO_USABLE_ISSUER if _may_use_vcdm_issuer(payload, header) else ""))
        kid = header.get("kid")
        if kid is not None and not isinstance(kid, str):
            raise MalformedToken("kid must be a string")
        return iss, 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).
        It refuses (``SdJwtError``, nothing signed) the claims a verifier needs to judge
        validity, since a holder could withhold them undetected: ``iss``, ``nbf``,
        ``exp``, ``cnf``, ``vct``, ``vct#integrity``, ``aka_vcts`` and ``status``
        (draft-ietf-oauth-sd-jwt-vc-19 §2.2.2.3), ``credentialStatus`` and ``@context``,
        and — when ``@context`` makes the payload a W3C VCDM credential — ``validFrom``,
        ``validUntil``, ``issuanceDate`` and ``expirationDate``. ``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 (isinstance(payload.get("vct"), str) and payload["vct"]):
            # what verify() requires of a dc+sd-jwt: anything else would not verify
            raise SdJwtError("an SD-JWT VC needs a 'vct' claim (a non-empty string)")
        _check_aka_vcts(payload)
        if expires_in_s is not None:
            payload["exp"] = now + expires_in_s
        if holder_jwk is not None:
            payload["cnf"] = {"jwk": holder_jwk}

        names = list(disclosable)
        # Judge VCDM-ness on the payload as the verifier will parse it: a tuple
        # `@context` is not a list here, but it is a JSON array once signed.
        vcdm = _declares_vcdm(json.loads(_json_bytes(payload)))
        for name in names:                     # refuse before any disclosure is built
            reason = _disclosure_refusal(name, vcdm=vcdm)
            if reason is not None:
                raise SdJwtError(
                    f"claim {name!r} must not be selectively disclosable ({reason}): a "
                    "holder could withhold it, and a verifier cannot tell a withheld "
                    "disclosure from an absent claim")

        disclosures: list[str] = []
        digests: list[str] = []
        for name in names:
            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](_ascii_bytes(presented)).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,
        check_temporal: bool = True,
        kb_max_age_s: int = DEFAULT_KB_MAX_AGE_S,
        now: datetime | 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``.

        *audience* and *nonce* bind a KB-JWT **together** (RFC 9901 §7.3 step 5.6):
        pass both, as non-empty strings. A present KB-JWT verified with only one of
        them, or with an empty one, raises ``ClaimsInvalid`` rather than report a
        half-checked binding. With neither, ``key_bound=True`` proves possession of the
        ``cnf`` key over these disclosures for *some* verifier, not a binding to you.
        Rejecting a half-bound KB-JWT does not make it *required*: a presentation with
        the KB-JWT stripped still verifies with ``key_bound=False``, so set
        *require_key_binding* (or check ``key_bound``) when you need the binding.

        When *require_key_binding* is True the KB-JWT is verified **before**
        ``exp``/``nbf``: an expired (or not-yet-valid) issuer JWT still has its
        holder binding checked, so a temporal failure means the presentation was
        bound and a key-binding failure means it was not. The hosted path
        (*require_key_binding* False) is unchanged — temporal still runs before
        any optional KB-JWT.

        Any KB-JWT that is present is held to RFC 9901 §7.3 step 5.5: its ``iat`` is
        required, must be a finite number, must not be older than *kb_max_age_s* (plus
        the suite leeway) and must not be in the future beyond the leeway. *now* pins
        the instant that window is evaluated at (a naive datetime is UTC). To re-check
        an archived presentation, pin it to the **receipt time your system recorded** —
        never to a value read from the presentation itself: its own ``iat`` would make
        every KB-JWT fresh and switch the window off. The issuer JWT's ``exp``/``nbf``/``iat``
        still use the current time, as on every JOSE suite.

        The issuer JWT ``typ`` is ``dc+sd-jwt`` (an IETF SD-JWT VC) or ``vc+sd-jwt``
        (VC-JOSE-COSE §6.1.3). The draft-era ``vc+ld+json+sd-jwt`` of the Recommendation's
        Example 21 and an absent ``typ`` (a SHOULD there; its Examples 4 and 5 omit it) are
        accepted like ``vc+sd-jwt``. All of these but ``dc+sd-jwt`` are accepted only for
        a payload that conforms to the VCDM (#275): a VCDM base ``@context`` somewhere in it
        (otherwise ``SdJwtError``), as the FIRST ``@context`` entry (VCDM 2.0 §4.3), and a
        ``type`` that includes ``VerifiableCredential`` and not ``VerifiablePresentation``
        (§4.5, §4.13), each with its own ``SdJwtError`` message; a withheld ``type`` fails the
        second. Nothing more is tested: ``issuer`` and ``credentialSubject`` are not required.
        A ``typ`` that is present and anything else, ``null`` included, raises ``SdJwtError``
        too, and so does an absent one on a payload with a ``vc`` or ``vp`` claim (§3.1.3).
        When the payload names a VCDM ``@context`` anywhere (whatever the ``typ``, a
        ``dc+sd-jwt`` included), it is a W3C VCDM credential for what follows, and its
        ``validFrom`` / ``validUntil`` are enforced too, like the VC-JWT suite does (current
        time, same leeway; ``check_temporal=False`` waives only the expiry). Its ``iss`` /
        ``jti`` / ``sub`` must not conflict with ``issuer`` / ``id`` /
        ``credentialSubject.id`` either, as on the VC-JWT suite (``ClaimsInvalid``). An IETF
        SD-JWT VC's claims are never read as a VCDM validity window or reconciled with them.

        The issuer JWT's ``exp``/``nbf``/``iat``, and any disclosed ``iat``, must each be
        a finite JSON number when present (``null`` included). An ``iat`` more than the
        leeway in the future is rejected. *check_temporal* waives only an expired ``exp``.

        The issuer JWT's own ``aud``, when it has one, is held to RFC 7519 §4.1.3 (ADR-0008
        D4): it must be a string or an array of strings, and the *audience* you pass must be
        one of them, else ``ClaimsInvalid``. With no *audience* a credential that names an
        audience is refused, since you cannot be one of them. An issuer JWT with no ``aud``
        verifies as before, with or without an *audience*. The check reads the processed
        payload, runs after the KB-JWT when binding is required, is not an expiry (so
        *check_temporal* does not waive it, and its message does not describe an expiry), and
        cannot see an ``aud`` disclosure the holder withholds. The KB-JWT's ``aud`` is a
        separate check: it binds the holder's presentation, not the issuer's restriction.

        A presented disclosure of a claim :meth:`issue` refuses to make disclosable — or of
        any sub-claim of one, array elements included — raises ``SdJwtError``: the issuer
        made it withholdable. That catches the non-conformant issuer only when the holder
        *presents* the disclosure; a withheld one is indistinguishable from a decoy (RFC
        9901 §9.7), so a third-party credential whose ``exp`` or ``status`` was disclosable
        still verifies when the holder omits it. Besides the issuer identifier (``iss``, or for
        a W3C credential with no ``iss`` its VCDM ``issuer``, #290), without which nothing
        verifies, the detectable case is ``vct``: it is REQUIRED, so a ``dc+sd-jwt`` whose
        processed payload has none raises ``ClaimsInvalid``, even one that also carries a
        VCDM ``@context``. With *require_key_binding*, a disclosed ``cnf`` fails earlier,
        as the key-binding ``ClaimsInvalid`` (no ``cnf.jwk`` in the issuer JWT).
        """
        non_negative_seconds(kb_max_age_s, name="kb_max_age_s", error=SdJwtError)
        # A naive `now` is UTC. Unpinned, the KB-JWT window reads the clock unrounded, as
        # check_jwt_temporal does: truncating it would accept a fractional KB-JWT `exp` up
        # to a second late. A pin is read to the second, as every openvc clock check is.
        current: float = time.time() if now is None else utc_epoch(now, error=SdJwtError)
        issuer_jwt, disclosures, kb_jwt = self._split(presentation)
        header, claims, signing_input, signature = self._decode_jws(issuer_jwt)

        # allow-list BEFORE crypto. A JSON array/object `alg` / `typ` is unhashable:
        # type-check it first, or `in frozenset` raises a bare TypeError
        alg = header.get("alg")
        if not isinstance(alg, str) or alg not in self._algs:
            raise UnsupportedAlgorithm(f"algorithm {safe_repr(alg)} is not permitted")
        # An ABSENT `typ` is let through (None) and judged on the payload below; one that is
        # present must be an accepted string. `null`, a number, an array and an object are
        # present and wrong, and a JSON array/object is unhashable, so type-check first.
        typ = header.get("typ")
        if "typ" in header and (not isinstance(typ, str) or typ not in _ACCEPTED_ISSUER_TYP):
            raise SdJwtError(f"unexpected issuer JWT typ {safe_repr(typ)}")
        reject_unknown_crit(header)
        self._verify_signature(alg, public_key_jwk, signing_input, signature,
                               what="issuer JWT")

        def _iss_and_hash() -> tuple[str, str]:
            # `header["typ"]` was accepted above: absent, a W3C name or dc+sd-jwt (#290)
            iss_claim = _issuer_from_body(claims, header)
            if not isinstance(iss_claim, str) or not iss_claim:
                raise ClaimsInvalid(
                    "iss claim is missing or not a string"
                    + (_NO_USABLE_ISSUER if _may_use_vcdm_issuer(claims, header) else ""))
            alg_name = claims.get("_sd_alg", _DEFAULT_HASH)
            if not isinstance(alg_name, str) or alg_name not in _HASHES:
                raise SdJwtError(f"unsupported _sd_alg {safe_repr(alg_name)}")
            return iss_claim, alg_name

        # Presentation path: prove holder binding even when the issuer JWT is
        # expired / not-yet-valid, then surface the temporal verdict. A
        # "token has expired" after this call means the KB-JWT verified.
        # Hosted / no-KB path: temporal first, same order as before.
        if require_key_binding:
            iss, hash_name = _iss_and_hash()
            key_bound = self._verify_key_binding(
                kb_jwt, issuer_jwt, disclosures, claims.get("cnf"),
                hash_name=hash_name, audience=audience, nonce=nonce,
                required=True, current=current, max_age_s=kb_max_age_s)
            expired = self._check_temporal(claims, check_temporal=check_temporal)
        else:
            expired = self._check_temporal(claims, check_temporal=check_temporal)
            iss, hash_name = _iss_and_hash()
            key_bound = False

        by_digest = self._index_disclosures(disclosures, hash_name)
        used: set[str] = set()
        placed: list[tuple[str | None, bool]] = []
        unpacked = _unpack(claims, by_digest, used, set(), placed=placed)
        unreferenced = set(by_digest) - used
        if unreferenced:
            raise SdJwtError(
                f"{len(unreferenced)} disclosure(s) not referenced by any digest")
        unpacked.pop("_sd_alg", None)

        # The profile rule on what _unpack placed: a presented disclosure of `exp`,
        # `status`, … (or of any sub-claim, array elements included) means the issuer made
        # it withholdable — fail visibly now rather than open when a holder withholds it.
        # `@context` is refused too, so the VCDM verdict below rests on issuer-signed
        # plaintext. This cannot see a disclosure that was withheld.
        vcdm = _declares_vcdm(unpacked)
        for name, nested in placed:
            reason = _disclosure_refusal(name, vcdm=vcdm)
            if reason is not None:
                what = "a sub-claim of" if nested else "claim"
                raise SdJwtError(
                    f"{what} {name!r} is selectively disclosed ({reason}): the issuer "
                    "made it withholdable, so its absence would go undetected")
        if typ != _ISSUER_TYP:
            # VC-JOSE-COSE §6.1.3 registers `vc+sd-jwt` for a VCDM payload, and draft-19
            # dropped the transitional acceptance of it for an IETF SD-JWT VC. Read as VCDM
            # by its typ alone, a `vct`-less or `vct`-withheld token skipped the REQUIRED
            # `vct` below (#264). A presented `@context` disclosure was refused above; a
            # withheld one leaves the payload not VCDM, so it is refused here too. The same
            # holds for the draft-era name and for an absent `typ` (#288, #289): none of them
            # says "IETF SD-JWT VC", so the payload has to say "W3C VC".
            shown = "without a typ" if typ is None else f"typ {typ!r}"
            if not vcdm:
                raise SdJwtError(
                    f"issuer JWT {shown} needs a W3C VCDM @context; an IETF SD-JWT VC "
                    "is typed 'dc+sd-jwt'")
            if typ is None and ("vc" in unpacked or "vp" in unpacked):
                # VC-JOSE-COSE §1.1.2.1, restated in §3.1.3: the claim names `vc` and `vp`
                # MUST NOT be present. A token with a `vc` object and no `typ` would be an
                # SD-JWT VC here and, with its `~` tail dropped, a VC-JWT in the VC-JWT lane.
                # That lane refuses a typed SD-JWT issuer JWT (#189) and, since #291, one
                # whose payload carries an SD-JWT marker, but not a zero-disclosure one with
                # no `typ`: the holder could shed key binding. A token accepted here has no
                # `vc` claim, so that lane refuses its stripped form ("no embedded `vc`
                # object"). The ban cannot help a token this lane never sees; the VC-JWT
                # lane's marker fence (#291) covers that one, and its zero-disclosure
                # residual is documented.
                raise SdJwtError(
                    "issuer JWT without a typ must not carry a `vc` or `vp` claim "
                    "(VC-JOSE-COSE §3.1.3)")
            # A VCDM base URL somewhere in `@context` is enough to enforce the window below
            # (the lax `vcdm`), but not to be read as a credential: the context has to START
            # with it and the `type` has to say credential, whatever the `typ` claims (#275;
            # #293 for an absent `typ`). Without this a payload with no `type`, no `issuer`
            # and a base URL in second place verified as a W3C credential.
            nonconformity = _vcdm_nonconformity(unpacked)
            if nonconformity is not None:
                raise SdJwtError(f"issuer JWT {shown} {nonconformity}")
        vct = unpacked.get("vct")
        if typ == _ISSUER_TYP and not (isinstance(vct, str) and vct):
            # draft-19 §2.2.2.3: `vct` is REQUIRED and never disclosable, so a withheld
            # disclosure of it is detectable: it is absent (like `iss`, without which the
            # issuer JWT never verified at all). §2.2.1: a `dc+sd-jwt` follows these rules
            # whatever else it carries, so a VCDM `@context` does not waive the `vct`
            # (#272); the VCDM window below still applies to it. A `vc+sd-jwt` is a VCDM
            # payload (refused above otherwise), typed by `type` instead.
            raise ClaimsInvalid("an SD-JWT VC (dc+sd-jwt) needs a non-empty string vct")

        # RFC 9901 §7.1 step 6: nbf/exp are checked on the *processed* payload if present.
        # A disclosed `exp`/`nbf` was refused above, so this re-reads the issuer-JWT body's;
        # it still checks a disclosed `iat`, and stays as defence in depth.
        expired = self._check_temporal(unpacked, check_temporal=check_temporal) or expired

        # RFC 7519 §4.1.3 on the issuer JWT's own `aud` (ADR-0008 D4): an issuer that
        # restricted the credential to a relying party is obeyed, whatever the KB-JWT says
        # (its `aud` binds the holder's presentation, not the issuer's restriction). Present
        # only: an issuer JWT without `aud` is the norm and stays as it was. It reads the
        # processed payload: a plaintext `aud` always reaches it (a disclosure can never
        # overwrite a claim), an `aud` the issuer made disclosable is judged once
        # disclosed, and an array whose entries are digests (RFC 9901 §9.7 lets an issuer
        # do that) is judged on the entries the holder disclosed, not as a malformed array.
        # No waiver applies, and the message does not describe an expiry (wiki/SD-JWT-VC.md).
        check_jwt_audience(unpacked, audience, subject="issuer JWT", require_present=False)

        # A W3C credential secured as SD-JWT *is* this claim set (VC-JOSE-COSE §3.2.1), and
        # its `exp` dates the signature, not the data (§3.1.3, JOSE): an issuer may put the
        # expiry only in `validUntil`. Enforce the VCDM window as the VC-JWT lane does — on
        # the processed payload, same leeway, wall clock like `exp`.
        if vcdm:
            expired = check_validity_window(
                unpacked, {}, now=None, leeway_s=self._leeway,
                check_temporal=check_temporal) or expired
            # §4.1.2: `iss`, if present, MUST match `issuer` (or `issuer.id`); §3.1.3 (non-
            # normative) advises the same for `jti`/`id` and `sub`/`credentialSubject.id`.
            # The VC-JWT lane's reconciliation: a present `issuer` names the verified iss.
            VcJwtProofSuite._reconcile({**unpacked, "iss": iss}, unpacked)

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

        if not require_key_binding:
            key_bound = self._verify_key_binding(
                kb_jwt, issuer_jwt, disclosures, claims.get("cnf"),
                hash_name=hash_name, audience=audience, nonce=nonce,
                required=False, current=current, max_age_s=kb_max_age_s)

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

    # -- 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], *, check_temporal: bool = True
    ) -> bool:
        # NumericDate exp/nbf/iat, fail-closed (null, NaN, strings and bignums rejected)
        # and a future iat bounded, single-sourced across the JOSE suites
        # (openvc.proof._verify_common.check_jwt_temporal). Runs on the issuer-JWT body
        # and again on the unpacked claims, where a disclosed iat first appears. True
        # when a waived exp has passed.
        return check_jwt_temporal(
            claims, leeway_s=self._leeway, subject="token",
            check_temporal=check_temporal)

    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,
        current: float,
        max_age_s: int,
    ) -> 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
        if audience is not None or nonce is not None:
            # RFC 9901 §7.3 step 5.6 binds a KB-JWT by aud AND nonce: aud alone lets a
            # KB-JWT from another of this verifier's sessions replay, nonce alone lets one
            # made for another verifier relay. Checking only the value given would report
            # key_bound=True for a binding that never happened, so a half-configured
            # caller fails closed here, before any KB-JWT crypto. Only with a KB-JWT
            # present: `audience` alone is a valid VC-JWT policy, and an SD-JWT without a
            # KB-JWT honestly reports key_bound=False.
            if audience is None or nonce is None:
                given = "audience" if nonce is None else "nonce"
                raise ClaimsInvalid(
                    f"a KB-JWT is present but only {given} was given to bind it against; "
                    "aud and nonce bind it together (RFC 9901 §7.3 step 5.6), so pass both")
            # An empty (or non-string) value binds nothing: `nonce=""` from a lost session
            # would accept any KB-JWT the holder signed with an empty nonce. Every other
            # binding gate (VP-JWT, OpenID4VP, the nested _kb_binding) refuses it too.
            if not (isinstance(audience, str) and audience
                    and isinstance(nonce, str) and nonce):
                raise ClaimsInvalid(
                    "the audience and nonce a KB-JWT is bound against must be non-empty "
                    "strings")
        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 not isinstance(alg, str) or alg not in self._algs:     # unhashable-safe
            raise UnsupportedAlgorithm(
                f"KB-JWT algorithm {safe_repr(alg)} 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")

        # RFC 9901 §4.3: `iat` is REQUIRED; §7.3 step 5.5: within an acceptable window.
        # The nonce binds a KB-JWT to one transaction, but only the caller knows how old
        # that nonce is — this window is the freshness bound a stateless verifier owns.
        check_iat_freshness(
            claims.get("iat"), max_age_s=max_age_s, leeway_s=self._leeway,
            current=current, subject="KB-JWT")
        # RFC 9901 defines no exp/nbf on a KB-JWT, but a signed one is not ignored: same
        # shape rule as the issuer JWT, evaluated at the same `current` as the iat window.
        # A window failure is a binding failure, phrased without "expired": callers read
        # an `expired` ClaimsInvalid from a required-binding verify as "bound, but the
        # issuer JWT expired" (wiki/SD-JWT-VC.md), and no waiver applies here.
        check_numeric_dates(claims, "exp", "nbf", subject="KB-JWT")
        try:
            check_jwt_temporal(claims, leeway_s=self._leeway, subject="KB-JWT",
                               now=current)
        except ClaimsInvalid as exc:
            raise ClaimsInvalid("KB-JWT is outside its own exp/nbf window") from exc
        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](_ascii_bytes(presented)).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.

For a W3C credential (typ absent, vc+sd-jwt or vc+ld+json+sd-jwt) whose body has no iss at all, the first item is the VCDM issuer (a string, or an object's id): VC-JOSE-COSE §4.1.2 makes iss optional there (#290). Stricter than the VC-JWT lane's peek, which reads vc.issuer for any falsy iss: only when iss is ABSENT, and an object's id / @id must all agree. A dc+sd-jwt still needs iss.

Source code in src/openvc/proof/sd_jwt.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
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.

    For a W3C credential (``typ`` absent, ``vc+sd-jwt`` or ``vc+ld+json+sd-jwt``) whose
    body has no ``iss`` at all, the first item is the VCDM ``issuer`` (a string, or an
    object's ``id``): VC-JOSE-COSE §4.1.2 makes ``iss`` optional there (#290). Stricter
    than the VC-JWT lane's peek, which reads ``vc.issuer`` for any falsy ``iss``: only
    when ``iss`` is ABSENT, and an object's ``id`` / ``@id`` must all agree. A
    ``dc+sd-jwt`` still needs ``iss``."""
    header, payload, _, _ = self._decode_jws(sd_jwt.split("~", 1)[0])
    iss = _issuer_from_body(payload, header)
    if not iss or not isinstance(iss, str):
        raise MalformedToken(
            "no iss in the issuer-signed JWT"
            + (_NO_USABLE_ISSUER if _may_use_vcdm_issuer(payload, header) else ""))
    kid = header.get("kid")
    if kid is not None and not isinstance(kid, str):
        raise MalformedToken("kid must be a string")
    return iss, 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). It refuses (SdJwtError, nothing signed) the claims a verifier needs to judge validity, since a holder could withhold them undetected: iss, nbf, exp, cnf, vct, vct#integrity, aka_vcts and status (draft-ietf-oauth-sd-jwt-vc-19 §2.2.2.3), credentialStatus and @context, and — when @context makes the payload a W3C VCDM credential — validFrom, validUntil, issuanceDate and expirationDate. 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
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
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).
    It refuses (``SdJwtError``, nothing signed) the claims a verifier needs to judge
    validity, since a holder could withhold them undetected: ``iss``, ``nbf``,
    ``exp``, ``cnf``, ``vct``, ``vct#integrity``, ``aka_vcts`` and ``status``
    (draft-ietf-oauth-sd-jwt-vc-19 §2.2.2.3), ``credentialStatus`` and ``@context``,
    and — when ``@context`` makes the payload a W3C VCDM credential — ``validFrom``,
    ``validUntil``, ``issuanceDate`` and ``expirationDate``. ``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 (isinstance(payload.get("vct"), str) and payload["vct"]):
        # what verify() requires of a dc+sd-jwt: anything else would not verify
        raise SdJwtError("an SD-JWT VC needs a 'vct' claim (a non-empty string)")
    _check_aka_vcts(payload)
    if expires_in_s is not None:
        payload["exp"] = now + expires_in_s
    if holder_jwk is not None:
        payload["cnf"] = {"jwk": holder_jwk}

    names = list(disclosable)
    # Judge VCDM-ness on the payload as the verifier will parse it: a tuple
    # `@context` is not a list here, but it is a JSON array once signed.
    vcdm = _declares_vcdm(json.loads(_json_bytes(payload)))
    for name in names:                     # refuse before any disclosure is built
        reason = _disclosure_refusal(name, vcdm=vcdm)
        if reason is not None:
            raise SdJwtError(
                f"claim {name!r} must not be selectively disclosable ({reason}): a "
                "holder could withhold it, and a verifier cannot tell a withheld "
                "disclosure from an absent claim")

    disclosures: list[str] = []
    digests: list[str] = []
    for name in names:
        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
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
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](_ascii_bytes(presented)).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, check_temporal=True, kb_max_age_s=DEFAULT_KB_MAX_AGE_S, now=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.

audience and nonce bind a KB-JWT together (RFC 9901 §7.3 step 5.6): pass both, as non-empty strings. A present KB-JWT verified with only one of them, or with an empty one, raises ClaimsInvalid rather than report a half-checked binding. With neither, key_bound=True proves possession of the cnf key over these disclosures for some verifier, not a binding to you. Rejecting a half-bound KB-JWT does not make it required: a presentation with the KB-JWT stripped still verifies with key_bound=False, so set require_key_binding (or check key_bound) when you need the binding.

When require_key_binding is True the KB-JWT is verified before exp/nbf: an expired (or not-yet-valid) issuer JWT still has its holder binding checked, so a temporal failure means the presentation was bound and a key-binding failure means it was not. The hosted path (require_key_binding False) is unchanged — temporal still runs before any optional KB-JWT.

Any KB-JWT that is present is held to RFC 9901 §7.3 step 5.5: its iat is required, must be a finite number, must not be older than kb_max_age_s (plus the suite leeway) and must not be in the future beyond the leeway. now pins the instant that window is evaluated at (a naive datetime is UTC). To re-check an archived presentation, pin it to the receipt time your system recorded — never to a value read from the presentation itself: its own iat would make every KB-JWT fresh and switch the window off. The issuer JWT's exp/nbf/iat still use the current time, as on every JOSE suite.

The issuer JWT typ is dc+sd-jwt (an IETF SD-JWT VC) or vc+sd-jwt (VC-JOSE-COSE §6.1.3). The draft-era vc+ld+json+sd-jwt of the Recommendation's Example 21 and an absent typ (a SHOULD there; its Examples 4 and 5 omit it) are accepted like vc+sd-jwt. All of these but dc+sd-jwt are accepted only for a payload that conforms to the VCDM (#275): a VCDM base @context somewhere in it (otherwise SdJwtError), as the FIRST @context entry (VCDM 2.0 §4.3), and a type that includes VerifiableCredential and not VerifiablePresentation (§4.5, §4.13), each with its own SdJwtError message; a withheld type fails the second. Nothing more is tested: issuer and credentialSubject are not required. A typ that is present and anything else, null included, raises SdJwtError too, and so does an absent one on a payload with a vc or vp claim (§3.1.3). When the payload names a VCDM @context anywhere (whatever the typ, a dc+sd-jwt included), it is a W3C VCDM credential for what follows, and its validFrom / validUntil are enforced too, like the VC-JWT suite does (current time, same leeway; check_temporal=False waives only the expiry). Its iss / jti / sub must not conflict with issuer / id / credentialSubject.id either, as on the VC-JWT suite (ClaimsInvalid). An IETF SD-JWT VC's claims are never read as a VCDM validity window or reconciled with them.

The issuer JWT's exp/nbf/iat, and any disclosed iat, must each be a finite JSON number when present (null included). An iat more than the leeway in the future is rejected. check_temporal waives only an expired exp.

The issuer JWT's own aud, when it has one, is held to RFC 7519 §4.1.3 (ADR-0008 D4): it must be a string or an array of strings, and the audience you pass must be one of them, else ClaimsInvalid. With no audience a credential that names an audience is refused, since you cannot be one of them. An issuer JWT with no aud verifies as before, with or without an audience. The check reads the processed payload, runs after the KB-JWT when binding is required, is not an expiry (so check_temporal does not waive it, and its message does not describe an expiry), and cannot see an aud disclosure the holder withholds. The KB-JWT's aud is a separate check: it binds the holder's presentation, not the issuer's restriction.

A presented disclosure of a claim :meth:issue refuses to make disclosable — or of any sub-claim of one, array elements included — raises SdJwtError: the issuer made it withholdable. That catches the non-conformant issuer only when the holder presents the disclosure; a withheld one is indistinguishable from a decoy (RFC 9901 §9.7), so a third-party credential whose exp or status was disclosable still verifies when the holder omits it. Besides the issuer identifier (iss, or for a W3C credential with no iss its VCDM issuer, #290), without which nothing verifies, the detectable case is vct: it is REQUIRED, so a dc+sd-jwt whose processed payload has none raises ClaimsInvalid, even one that also carries a VCDM @context. With require_key_binding, a disclosed cnf fails earlier, as the key-binding ClaimsInvalid (no cnf.jwk in the issuer JWT).

Source code in src/openvc/proof/sd_jwt.py
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
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
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,
    check_temporal: bool = True,
    kb_max_age_s: int = DEFAULT_KB_MAX_AGE_S,
    now: datetime | 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``.

    *audience* and *nonce* bind a KB-JWT **together** (RFC 9901 §7.3 step 5.6):
    pass both, as non-empty strings. A present KB-JWT verified with only one of
    them, or with an empty one, raises ``ClaimsInvalid`` rather than report a
    half-checked binding. With neither, ``key_bound=True`` proves possession of the
    ``cnf`` key over these disclosures for *some* verifier, not a binding to you.
    Rejecting a half-bound KB-JWT does not make it *required*: a presentation with
    the KB-JWT stripped still verifies with ``key_bound=False``, so set
    *require_key_binding* (or check ``key_bound``) when you need the binding.

    When *require_key_binding* is True the KB-JWT is verified **before**
    ``exp``/``nbf``: an expired (or not-yet-valid) issuer JWT still has its
    holder binding checked, so a temporal failure means the presentation was
    bound and a key-binding failure means it was not. The hosted path
    (*require_key_binding* False) is unchanged — temporal still runs before
    any optional KB-JWT.

    Any KB-JWT that is present is held to RFC 9901 §7.3 step 5.5: its ``iat`` is
    required, must be a finite number, must not be older than *kb_max_age_s* (plus
    the suite leeway) and must not be in the future beyond the leeway. *now* pins
    the instant that window is evaluated at (a naive datetime is UTC). To re-check
    an archived presentation, pin it to the **receipt time your system recorded** —
    never to a value read from the presentation itself: its own ``iat`` would make
    every KB-JWT fresh and switch the window off. The issuer JWT's ``exp``/``nbf``/``iat``
    still use the current time, as on every JOSE suite.

    The issuer JWT ``typ`` is ``dc+sd-jwt`` (an IETF SD-JWT VC) or ``vc+sd-jwt``
    (VC-JOSE-COSE §6.1.3). The draft-era ``vc+ld+json+sd-jwt`` of the Recommendation's
    Example 21 and an absent ``typ`` (a SHOULD there; its Examples 4 and 5 omit it) are
    accepted like ``vc+sd-jwt``. All of these but ``dc+sd-jwt`` are accepted only for
    a payload that conforms to the VCDM (#275): a VCDM base ``@context`` somewhere in it
    (otherwise ``SdJwtError``), as the FIRST ``@context`` entry (VCDM 2.0 §4.3), and a
    ``type`` that includes ``VerifiableCredential`` and not ``VerifiablePresentation``
    (§4.5, §4.13), each with its own ``SdJwtError`` message; a withheld ``type`` fails the
    second. Nothing more is tested: ``issuer`` and ``credentialSubject`` are not required.
    A ``typ`` that is present and anything else, ``null`` included, raises ``SdJwtError``
    too, and so does an absent one on a payload with a ``vc`` or ``vp`` claim (§3.1.3).
    When the payload names a VCDM ``@context`` anywhere (whatever the ``typ``, a
    ``dc+sd-jwt`` included), it is a W3C VCDM credential for what follows, and its
    ``validFrom`` / ``validUntil`` are enforced too, like the VC-JWT suite does (current
    time, same leeway; ``check_temporal=False`` waives only the expiry). Its ``iss`` /
    ``jti`` / ``sub`` must not conflict with ``issuer`` / ``id`` /
    ``credentialSubject.id`` either, as on the VC-JWT suite (``ClaimsInvalid``). An IETF
    SD-JWT VC's claims are never read as a VCDM validity window or reconciled with them.

    The issuer JWT's ``exp``/``nbf``/``iat``, and any disclosed ``iat``, must each be
    a finite JSON number when present (``null`` included). An ``iat`` more than the
    leeway in the future is rejected. *check_temporal* waives only an expired ``exp``.

    The issuer JWT's own ``aud``, when it has one, is held to RFC 7519 §4.1.3 (ADR-0008
    D4): it must be a string or an array of strings, and the *audience* you pass must be
    one of them, else ``ClaimsInvalid``. With no *audience* a credential that names an
    audience is refused, since you cannot be one of them. An issuer JWT with no ``aud``
    verifies as before, with or without an *audience*. The check reads the processed
    payload, runs after the KB-JWT when binding is required, is not an expiry (so
    *check_temporal* does not waive it, and its message does not describe an expiry), and
    cannot see an ``aud`` disclosure the holder withholds. The KB-JWT's ``aud`` is a
    separate check: it binds the holder's presentation, not the issuer's restriction.

    A presented disclosure of a claim :meth:`issue` refuses to make disclosable — or of
    any sub-claim of one, array elements included — raises ``SdJwtError``: the issuer
    made it withholdable. That catches the non-conformant issuer only when the holder
    *presents* the disclosure; a withheld one is indistinguishable from a decoy (RFC
    9901 §9.7), so a third-party credential whose ``exp`` or ``status`` was disclosable
    still verifies when the holder omits it. Besides the issuer identifier (``iss``, or for
    a W3C credential with no ``iss`` its VCDM ``issuer``, #290), without which nothing
    verifies, the detectable case is ``vct``: it is REQUIRED, so a ``dc+sd-jwt`` whose
    processed payload has none raises ``ClaimsInvalid``, even one that also carries a
    VCDM ``@context``. With *require_key_binding*, a disclosed ``cnf`` fails earlier,
    as the key-binding ``ClaimsInvalid`` (no ``cnf.jwk`` in the issuer JWT).
    """
    non_negative_seconds(kb_max_age_s, name="kb_max_age_s", error=SdJwtError)
    # A naive `now` is UTC. Unpinned, the KB-JWT window reads the clock unrounded, as
    # check_jwt_temporal does: truncating it would accept a fractional KB-JWT `exp` up
    # to a second late. A pin is read to the second, as every openvc clock check is.
    current: float = time.time() if now is None else utc_epoch(now, error=SdJwtError)
    issuer_jwt, disclosures, kb_jwt = self._split(presentation)
    header, claims, signing_input, signature = self._decode_jws(issuer_jwt)

    # allow-list BEFORE crypto. A JSON array/object `alg` / `typ` is unhashable:
    # type-check it first, or `in frozenset` raises a bare TypeError
    alg = header.get("alg")
    if not isinstance(alg, str) or alg not in self._algs:
        raise UnsupportedAlgorithm(f"algorithm {safe_repr(alg)} is not permitted")
    # An ABSENT `typ` is let through (None) and judged on the payload below; one that is
    # present must be an accepted string. `null`, a number, an array and an object are
    # present and wrong, and a JSON array/object is unhashable, so type-check first.
    typ = header.get("typ")
    if "typ" in header and (not isinstance(typ, str) or typ not in _ACCEPTED_ISSUER_TYP):
        raise SdJwtError(f"unexpected issuer JWT typ {safe_repr(typ)}")
    reject_unknown_crit(header)
    self._verify_signature(alg, public_key_jwk, signing_input, signature,
                           what="issuer JWT")

    def _iss_and_hash() -> tuple[str, str]:
        # `header["typ"]` was accepted above: absent, a W3C name or dc+sd-jwt (#290)
        iss_claim = _issuer_from_body(claims, header)
        if not isinstance(iss_claim, str) or not iss_claim:
            raise ClaimsInvalid(
                "iss claim is missing or not a string"
                + (_NO_USABLE_ISSUER if _may_use_vcdm_issuer(claims, header) else ""))
        alg_name = claims.get("_sd_alg", _DEFAULT_HASH)
        if not isinstance(alg_name, str) or alg_name not in _HASHES:
            raise SdJwtError(f"unsupported _sd_alg {safe_repr(alg_name)}")
        return iss_claim, alg_name

    # Presentation path: prove holder binding even when the issuer JWT is
    # expired / not-yet-valid, then surface the temporal verdict. A
    # "token has expired" after this call means the KB-JWT verified.
    # Hosted / no-KB path: temporal first, same order as before.
    if require_key_binding:
        iss, hash_name = _iss_and_hash()
        key_bound = self._verify_key_binding(
            kb_jwt, issuer_jwt, disclosures, claims.get("cnf"),
            hash_name=hash_name, audience=audience, nonce=nonce,
            required=True, current=current, max_age_s=kb_max_age_s)
        expired = self._check_temporal(claims, check_temporal=check_temporal)
    else:
        expired = self._check_temporal(claims, check_temporal=check_temporal)
        iss, hash_name = _iss_and_hash()
        key_bound = False

    by_digest = self._index_disclosures(disclosures, hash_name)
    used: set[str] = set()
    placed: list[tuple[str | None, bool]] = []
    unpacked = _unpack(claims, by_digest, used, set(), placed=placed)
    unreferenced = set(by_digest) - used
    if unreferenced:
        raise SdJwtError(
            f"{len(unreferenced)} disclosure(s) not referenced by any digest")
    unpacked.pop("_sd_alg", None)

    # The profile rule on what _unpack placed: a presented disclosure of `exp`,
    # `status`, … (or of any sub-claim, array elements included) means the issuer made
    # it withholdable — fail visibly now rather than open when a holder withholds it.
    # `@context` is refused too, so the VCDM verdict below rests on issuer-signed
    # plaintext. This cannot see a disclosure that was withheld.
    vcdm = _declares_vcdm(unpacked)
    for name, nested in placed:
        reason = _disclosure_refusal(name, vcdm=vcdm)
        if reason is not None:
            what = "a sub-claim of" if nested else "claim"
            raise SdJwtError(
                f"{what} {name!r} is selectively disclosed ({reason}): the issuer "
                "made it withholdable, so its absence would go undetected")
    if typ != _ISSUER_TYP:
        # VC-JOSE-COSE §6.1.3 registers `vc+sd-jwt` for a VCDM payload, and draft-19
        # dropped the transitional acceptance of it for an IETF SD-JWT VC. Read as VCDM
        # by its typ alone, a `vct`-less or `vct`-withheld token skipped the REQUIRED
        # `vct` below (#264). A presented `@context` disclosure was refused above; a
        # withheld one leaves the payload not VCDM, so it is refused here too. The same
        # holds for the draft-era name and for an absent `typ` (#288, #289): none of them
        # says "IETF SD-JWT VC", so the payload has to say "W3C VC".
        shown = "without a typ" if typ is None else f"typ {typ!r}"
        if not vcdm:
            raise SdJwtError(
                f"issuer JWT {shown} needs a W3C VCDM @context; an IETF SD-JWT VC "
                "is typed 'dc+sd-jwt'")
        if typ is None and ("vc" in unpacked or "vp" in unpacked):
            # VC-JOSE-COSE §1.1.2.1, restated in §3.1.3: the claim names `vc` and `vp`
            # MUST NOT be present. A token with a `vc` object and no `typ` would be an
            # SD-JWT VC here and, with its `~` tail dropped, a VC-JWT in the VC-JWT lane.
            # That lane refuses a typed SD-JWT issuer JWT (#189) and, since #291, one
            # whose payload carries an SD-JWT marker, but not a zero-disclosure one with
            # no `typ`: the holder could shed key binding. A token accepted here has no
            # `vc` claim, so that lane refuses its stripped form ("no embedded `vc`
            # object"). The ban cannot help a token this lane never sees; the VC-JWT
            # lane's marker fence (#291) covers that one, and its zero-disclosure
            # residual is documented.
            raise SdJwtError(
                "issuer JWT without a typ must not carry a `vc` or `vp` claim "
                "(VC-JOSE-COSE §3.1.3)")
        # A VCDM base URL somewhere in `@context` is enough to enforce the window below
        # (the lax `vcdm`), but not to be read as a credential: the context has to START
        # with it and the `type` has to say credential, whatever the `typ` claims (#275;
        # #293 for an absent `typ`). Without this a payload with no `type`, no `issuer`
        # and a base URL in second place verified as a W3C credential.
        nonconformity = _vcdm_nonconformity(unpacked)
        if nonconformity is not None:
            raise SdJwtError(f"issuer JWT {shown} {nonconformity}")
    vct = unpacked.get("vct")
    if typ == _ISSUER_TYP and not (isinstance(vct, str) and vct):
        # draft-19 §2.2.2.3: `vct` is REQUIRED and never disclosable, so a withheld
        # disclosure of it is detectable: it is absent (like `iss`, without which the
        # issuer JWT never verified at all). §2.2.1: a `dc+sd-jwt` follows these rules
        # whatever else it carries, so a VCDM `@context` does not waive the `vct`
        # (#272); the VCDM window below still applies to it. A `vc+sd-jwt` is a VCDM
        # payload (refused above otherwise), typed by `type` instead.
        raise ClaimsInvalid("an SD-JWT VC (dc+sd-jwt) needs a non-empty string vct")

    # RFC 9901 §7.1 step 6: nbf/exp are checked on the *processed* payload if present.
    # A disclosed `exp`/`nbf` was refused above, so this re-reads the issuer-JWT body's;
    # it still checks a disclosed `iat`, and stays as defence in depth.
    expired = self._check_temporal(unpacked, check_temporal=check_temporal) or expired

    # RFC 7519 §4.1.3 on the issuer JWT's own `aud` (ADR-0008 D4): an issuer that
    # restricted the credential to a relying party is obeyed, whatever the KB-JWT says
    # (its `aud` binds the holder's presentation, not the issuer's restriction). Present
    # only: an issuer JWT without `aud` is the norm and stays as it was. It reads the
    # processed payload: a plaintext `aud` always reaches it (a disclosure can never
    # overwrite a claim), an `aud` the issuer made disclosable is judged once
    # disclosed, and an array whose entries are digests (RFC 9901 §9.7 lets an issuer
    # do that) is judged on the entries the holder disclosed, not as a malformed array.
    # No waiver applies, and the message does not describe an expiry (wiki/SD-JWT-VC.md).
    check_jwt_audience(unpacked, audience, subject="issuer JWT", require_present=False)

    # A W3C credential secured as SD-JWT *is* this claim set (VC-JOSE-COSE §3.2.1), and
    # its `exp` dates the signature, not the data (§3.1.3, JOSE): an issuer may put the
    # expiry only in `validUntil`. Enforce the VCDM window as the VC-JWT lane does — on
    # the processed payload, same leeway, wall clock like `exp`.
    if vcdm:
        expired = check_validity_window(
            unpacked, {}, now=None, leeway_s=self._leeway,
            check_temporal=check_temporal) or expired
        # §4.1.2: `iss`, if present, MUST match `issuer` (or `issuer.id`); §3.1.3 (non-
        # normative) advises the same for `jti`/`id` and `sub`/`credentialSubject.id`.
        # The VC-JWT lane's reconciliation: a present `issuer` names the verified iss.
        VcJwtProofSuite._reconcile({**unpacked, "iss": iss}, unpacked)

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

    if not require_key_binding:
        key_bound = self._verify_key_binding(
            kb_jwt, issuer_jwt, disclosures, claims.get("cnf"),
            hash_name=hash_name, audience=audience, nonce=nonce,
            required=False, current=current, max_age_s=kb_max_age_s)

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

make_object_disclosure(salt, name, value)

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

Source code in src/openvc/proof/sd_jwt.py
155
156
157
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
160
161
162
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
165
166
167
168
169
170
171
172
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
57
58
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
61
62
class CredentialNotYetValid(ProofError):
    """The credential's validity window has not started (validFrom / issuanceDate)."""

KeyResolutionError

Bases: ProofError, KeyResolutionFailed

The proof's verificationMethod key could not be resolved.

Raised by every Data Integrity lane, sync and async. It is both a :class:ProofError and the pipeline's :class:~openvc.errors.KeyResolutionFailed (hence also a :class:~openvc.errors.VerificationError), so except ProofError, except KeyResolutionFailed and except VerificationError each catch it. The JOSE lanes raise the plain KeyResolutionFailed, which is not a ProofError, so except KeyResolutionError and except ProofError still do not catch a JOSE failure.

Source code in src/openvc/proof/errors.py
73
74
75
76
77
78
79
80
81
82
class KeyResolutionError(ProofError, KeyResolutionFailed):
    """The proof's verificationMethod key could not be resolved.

    Raised by every Data Integrity lane, sync and async. It is both a :class:`ProofError`
    and the pipeline's :class:`~openvc.errors.KeyResolutionFailed` (hence also a
    :class:`~openvc.errors.VerificationError`), so ``except ProofError``, ``except
    KeyResolutionFailed`` and ``except VerificationError`` each catch it. The JOSE lanes
    raise the plain ``KeyResolutionFailed``, which is not a ``ProofError``, so
    ``except KeyResolutionError`` and ``except ProofError`` still do not catch a JOSE
    failure."""

MalformedTimestamp

Bases: ProofError

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

Source code in src/openvc/proof/errors.py
65
66
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
85
86
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
69
70
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
35
36
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
31
32
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
39
40
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
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
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_seconds(leeway_s)       # fail closed, up front (0..MAX_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.

        *created* is a ``datetime`` or ``None`` (the current UTC time): a naive value is
        UTC, never the host's local time, an aware one is converted, and the signed
        string is whole seconds (truncated). Anything else, or a value with no UTC
        equivalent, is :class:`ProofMalformed` before anything is signed. No openvc
        verifier reads ``created`` back.
        """
        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": utc_iso(created, error=ProofMalformed, name="created"),
            "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)
        # never sign JSON the signature would not cover (see _context_validation)
        validate_document(_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,
        check_temporal: bool = True,
    ) -> 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")
        # The signature covers the RDF dataset; the checks below read the JSON. Context
        # validation (VC-DI §2.4.1) makes the two agree before any policy read.
        validate_document(unsecured, proof_config, loader)

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

        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,
            expired=expired)

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.

created is a datetime or None (the current UTC time): a naive value is UTC, never the host's local time, an aware one is converted, and the signed string is whole seconds (truncated). Anything else, or a value with no UTC equivalent, is :class:ProofMalformed before anything is signed. No openvc verifier reads created back.

Source code in src/openvc/proof/data_integrity.py
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
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.

    *created* is a ``datetime`` or ``None`` (the current UTC time): a naive value is
    UTC, never the host's local time, an aware one is converted, and the signed
    string is whole seconds (truncated). Anything else, or a value with no UTC
    equivalent, is :class:`ProofMalformed` before anything is signed. No openvc
    verifier reads ``created`` back.
    """
    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": utc_iso(created, error=ProofMalformed, name="created"),
        "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)
    # never sign JSON the signature would not cover (see _context_validation)
    validate_document(_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, check_temporal=True)

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
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
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,
    check_temporal: bool = True,
) -> 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")
    # The signature covers the RDF dataset; the checks below read the JSON. Context
    # validation (VC-DI §2.4.1) makes the two agree before any policy read.
    validate_document(unsecured, proof_config, loader)

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

    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,
        expired=expired)

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
 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
216
217
218
219
220
221
222
223
224
225
226
227
228
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_seconds(leeway_s)       # fail closed, up front (0..MAX_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*.

        *created* is a ``datetime`` or ``None`` (the current UTC time): a naive value is
        UTC, never the host's local time, an aware one is converted, and the signed
        string is whole seconds (truncated). Anything else, or a value with no UTC
        equivalent, is :class:`ProofMalformed` before anything is signed. No openvc
        verifier reads ``created`` back.
        """
        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": utc_iso(created, error=ProofMalformed, name="created"),
            "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)
        # never sign JSON the signature would not cover (see _context_validation)
        validate_document(_unsecured(credential), proof_config, loader)
        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,
        check_temporal: bool = True,
    ) -> 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")
        # The signature covers the RDF dataset; the checks below read the JSON. Context
        # validation (VC-DI §2.4.1) makes the two agree before any policy read.
        validate_document(unsecured, proof_config, loader)

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

        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,
            expired=expired)

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.

created is a datetime or None (the current UTC time): a naive value is UTC, never the host's local time, an aware one is converted, and the signed string is whole seconds (truncated). Anything else, or a value with no UTC equivalent, is :class:ProofMalformed before anything is signed. No openvc verifier reads created back.

Source code in src/openvc/proof/di_ecdsa_rdfc.py
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
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*.

    *created* is a ``datetime`` or ``None`` (the current UTC time): a naive value is
    UTC, never the host's local time, an aware one is converted, and the signed
    string is whole seconds (truncated). Anything else, or a value with no UTC
    equivalent, is :class:`ProofMalformed` before anything is signed. No openvc
    verifier reads ``created`` back.
    """
    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": utc_iso(created, error=ProofMalformed, name="created"),
        "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)
    # never sign JSON the signature would not cover (see _context_validation)
    validate_document(_unsecured(credential), proof_config, loader)
    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, check_temporal=True)

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
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
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,
    check_temporal: bool = True,
) -> 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")
    # The signature covers the RDF dataset; the checks below read the JSON. Context
    # validation (VC-DI §2.4.1) makes the two agree before any policy read.
    validate_document(unsecured, proof_config, loader)

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

    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,
        expired=expired)

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
57
58
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
61
62
class CredentialNotYetValid(ProofError):
    """The credential's validity window has not started (validFrom / issuanceDate)."""

KeyResolutionError

Bases: ProofError, KeyResolutionFailed

The proof's verificationMethod key could not be resolved.

Raised by every Data Integrity lane, sync and async. It is both a :class:ProofError and the pipeline's :class:~openvc.errors.KeyResolutionFailed (hence also a :class:~openvc.errors.VerificationError), so except ProofError, except KeyResolutionFailed and except VerificationError each catch it. The JOSE lanes raise the plain KeyResolutionFailed, which is not a ProofError, so except KeyResolutionError and except ProofError still do not catch a JOSE failure.

Source code in src/openvc/proof/errors.py
73
74
75
76
77
78
79
80
81
82
class KeyResolutionError(ProofError, KeyResolutionFailed):
    """The proof's verificationMethod key could not be resolved.

    Raised by every Data Integrity lane, sync and async. It is both a :class:`ProofError`
    and the pipeline's :class:`~openvc.errors.KeyResolutionFailed` (hence also a
    :class:`~openvc.errors.VerificationError`), so ``except ProofError``, ``except
    KeyResolutionFailed`` and ``except VerificationError`` each catch it. The JOSE lanes
    raise the plain ``KeyResolutionFailed``, which is not a ``ProofError``, so
    ``except KeyResolutionError`` and ``except ProofError`` still do not catch a JOSE
    failure."""

MalformedTimestamp

Bases: ProofError

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

Source code in src/openvc/proof/errors.py
65
66
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
69
70
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
35
36
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
31
32
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
39
40
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
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
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
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_seconds(leeway_s)       # fail closed, up front (0..MAX_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.

        *created* is a ``datetime`` or ``None`` (the current UTC time): a naive value is
        UTC, never the host's local time, an aware one is converted, and the signed
        string is whole seconds (truncated). Anything else, or a value with no UTC
        equivalent, is :class:`ProofMalformed` before anything is signed. No openvc
        verifier reads ``created`` back.

        Raises :class:`ProofMalformed` if the credential cannot be canonicalized: an
        ``@context`` that is neither bundled nor passed via *extra_contexts*, or one
        JSON-LD refuses (the escaping class used to be ``pyld``'s ``JsonLdError``).
        A signing-backend error is not wrapped: it surfaces as itself."""
        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": utc_iso(created, error=ProofMalformed, name="created"),
            "verificationMethod": verification_method, "proofPurpose": proof_purpose,
        }
        try:
            proof_hash = _proof_config_hash(proof, credential["@context"], loader)
            # never sign JSON the signature would not cover (see _context_validation)
            validate_document(credential, _proof_config(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)
        except ProofError:
            raise
        except Exception as exc:
            # The same arm verify has. pyld raises its own JsonLdError (an @context it
            # cannot process) next to the usual value/type errors, and none is an
            # OpenvcError. Only canonicalization is guarded: the signing calls below are
            # outside, so an HSM / Vault backend error surfaces as itself.
            raise _canonicalization_failure("credential", exc) from exc

        ephemeral = P256SigningKey.generate(kid="urn:ephemeral")
        public_key = p256_public_multikey(ephemeral.public_jwk())
        signatures = [ephemeral.sign(_utf8_nquads(ln + "\n", "a non-mandatory statement"))
                      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.

        Raises :class:`ProofMalformed` if the credential cannot be canonicalized: an
        ``@context`` that is neither bundled nor passed via *extra_contexts*, or one
        JSON-LD refuses (the escaping class used to be ``pyld``'s ``JsonLdError``).
        A pointer that does not resolve is :class:`EcdsaSdError`."""
        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"}
        try:
            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)
        except ProofError:
            raise
        except Exception as exc:
            # The credential is the one the holder received, so its issuer chose the
            # @context and the body: whatever pyld refuses is a typed ProofMalformed, as in
            # verify, not a bare JsonLdError. A bad pointer (EcdsaSdError) passes through.
            raise _canonicalization_failure("credential", exc) from exc

        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. Like the verifier,
        this does not deskolemize: a ``urn:bnid:`` IRI left in the reveal is an IRI, and
        its statement has no base signature."""
        c14n_lines, id_map = _canonicalize(_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,
        check_temporal: bool = True,
    ) -> 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."""
        if _json.nesting_exceeds(derived):        # a dict handed over already parsed
            raise ProofMalformed(
                f"credential is nested deeper than {_json.MAX_DEPTH} levels")
        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 {safe_repr(proof.get('cryptosuite'))}")
        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, AttributeError) as exc:
            # decode_derived_proof types every part; this is the backstop for a shape
            # it does not foresee, so the error never leaves the ProofError family.
            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"}
            # The document as presented, as VC-DI-ECDSA's verifier canonicalizes it: no
            # deskolemization. A `urn:bnid:` IRI here is an IRI. Turning it into a blank
            # node would let the holder merge it with one of the document's own blank
            # nodes (pyld labels them `_:bN`), which context validation, reading the
            # JSON, still sees as two nodes, or put an IRI id of its choosing on a node
            # the issuer signed as a blank node. With an injective label map
            # (decode_derived_proof), blank-node identity on the signature side is the
            # JSON-LD identity the checks after it rely on.
            c14n_lines, _ = _canonicalize(_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
            # (add_base_proof and derive_proof have the same arm).
            raise _canonicalization_failure("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=_utf8_nquads(line + "\n", "a disclosed statement"),
                    signature=sig):
                raise SignatureInvalid("a disclosed statement signature does not verify")
        # The signatures cover the disclosed RDF statements; the checks below read the
        # JSON. Context validation (VC-DI §2.4.1) makes the two agree first.
        validate_document(unsecured, _proof_config(proof, derived.get("@context")), loader)

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

        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,
            expired=expired)

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.

created is a datetime or None (the current UTC time): a naive value is UTC, never the host's local time, an aware one is converted, and the signed string is whole seconds (truncated). Anything else, or a value with no UTC equivalent, is :class:ProofMalformed before anything is signed. No openvc verifier reads created back.

Raises :class:ProofMalformed if the credential cannot be canonicalized: an @context that is neither bundled nor passed via extra_contexts, or one JSON-LD refuses (the escaping class used to be pyld's JsonLdError). A signing-backend error is not wrapped: it surfaces as itself.

Source code in src/openvc/proof/ecdsa_sd.py
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
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.

    *created* is a ``datetime`` or ``None`` (the current UTC time): a naive value is
    UTC, never the host's local time, an aware one is converted, and the signed
    string is whole seconds (truncated). Anything else, or a value with no UTC
    equivalent, is :class:`ProofMalformed` before anything is signed. No openvc
    verifier reads ``created`` back.

    Raises :class:`ProofMalformed` if the credential cannot be canonicalized: an
    ``@context`` that is neither bundled nor passed via *extra_contexts*, or one
    JSON-LD refuses (the escaping class used to be ``pyld``'s ``JsonLdError``).
    A signing-backend error is not wrapped: it surfaces as itself."""
    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": utc_iso(created, error=ProofMalformed, name="created"),
        "verificationMethod": verification_method, "proofPurpose": proof_purpose,
    }
    try:
        proof_hash = _proof_config_hash(proof, credential["@context"], loader)
        # never sign JSON the signature would not cover (see _context_validation)
        validate_document(credential, _proof_config(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)
    except ProofError:
        raise
    except Exception as exc:
        # The same arm verify has. pyld raises its own JsonLdError (an @context it
        # cannot process) next to the usual value/type errors, and none is an
        # OpenvcError. Only canonicalization is guarded: the signing calls below are
        # outside, so an HSM / Vault backend error surfaces as itself.
        raise _canonicalization_failure("credential", exc) from exc

    ephemeral = P256SigningKey.generate(kid="urn:ephemeral")
    public_key = p256_public_multikey(ephemeral.public_jwk())
    signatures = [ephemeral.sign(_utf8_nquads(ln + "\n", "a non-mandatory statement"))
                  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.

Raises :class:ProofMalformed if the credential cannot be canonicalized: an @context that is neither bundled nor passed via extra_contexts, or one JSON-LD refuses (the escaping class used to be pyld's JsonLdError). A pointer that does not resolve is :class:EcdsaSdError.

Source code in src/openvc/proof/ecdsa_sd.py
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
673
674
675
676
677
678
679
680
681
682
683
684
685
686
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.

    Raises :class:`ProofMalformed` if the credential cannot be canonicalized: an
    ``@context`` that is neither bundled nor passed via *extra_contexts*, or one
    JSON-LD refuses (the escaping class used to be ``pyld``'s ``JsonLdError``).
    A pointer that does not resolve is :class:`EcdsaSdError`."""
    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"}
    try:
        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)
    except ProofError:
        raise
    except Exception as exc:
        # The credential is the one the holder received, so its issuer chose the
        # @context and the body: whatever pyld refuses is a typed ProofMalformed, as in
        # verify, not a bare JsonLdError. A bad pointer (EcdsaSdError) passes through.
        raise _canonicalization_failure("credential", exc) from exc

    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, check_temporal=True)

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
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
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,
    check_temporal: bool = True,
) -> 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."""
    if _json.nesting_exceeds(derived):        # a dict handed over already parsed
        raise ProofMalformed(
            f"credential is nested deeper than {_json.MAX_DEPTH} levels")
    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 {safe_repr(proof.get('cryptosuite'))}")
    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, AttributeError) as exc:
        # decode_derived_proof types every part; this is the backstop for a shape
        # it does not foresee, so the error never leaves the ProofError family.
        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"}
        # The document as presented, as VC-DI-ECDSA's verifier canonicalizes it: no
        # deskolemization. A `urn:bnid:` IRI here is an IRI. Turning it into a blank
        # node would let the holder merge it with one of the document's own blank
        # nodes (pyld labels them `_:bN`), which context validation, reading the
        # JSON, still sees as two nodes, or put an IRI id of its choosing on a node
        # the issuer signed as a blank node. With an injective label map
        # (decode_derived_proof), blank-node identity on the signature side is the
        # JSON-LD identity the checks after it rely on.
        c14n_lines, _ = _canonicalize(_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
        # (add_base_proof and derive_proof have the same arm).
        raise _canonicalization_failure("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=_utf8_nquads(line + "\n", "a disclosed statement"),
                signature=sig):
            raise SignatureInvalid("a disclosed statement signature does not verify")
    # The signatures cover the disclosed RDF statements; the checks below read the
    # JSON. Context validation (VC-DI §2.4.1) makes the two agree first.
    validate_document(unsecured, _proof_config(proof, derived.get("@context")), loader)

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

    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,
        expired=expired)

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
129
130
131
132
133
134
135
136
137
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
140
141
142
143
144
145
146
147
148
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
155
156
157
158
159
160
161
162
163
164
165
166
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
169
170
171
172
173
174
175
176
177
178
179
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
186
187
188
189
190
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
193
194
195
196
197
198
199
200
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
203
204
205
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
212
213
214
215
216
217
218
219
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
222
223
224
225
226
227
228
229
230
231
232
233
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
236
237
238
239
240
241
242
243
244
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.

Each part must have the type VC-DI-ECDSA's parseDerivedProofValue gives it: base_signature and public_key byte strings, signatures an array of byte strings, the compressed label map a map of unsigned integers to byte strings and mandatory_indexes an array of unsigned integers. Anything else raises :class:ProofValueMalformed here, before :meth:EcdsaSdProofSuite.verify uses it. The holder chooses these bytes, and no key is needed to send them, so a wrong type would otherwise surface as a bare TypeError / AttributeError that aborts a whole verify_many batch.

The label map must also be injective. Two blank nodes of the disclosed document mapped to one HMAC label are one node to the signature and two to the JSON that every check after it reads, so a holder could describe one signed node (a status entry) in two places. A genuine derived proof never repeats a label: the disclosed blank nodes are distinct nodes of the issuer's document.

Source code in src/openvc/proof/ecdsa_sd.py
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
def decode_derived_proof(proof_value: str) -> dict[str, Any]:
    """Parse an ecdsa-sd-2023 derived proof value into its parts.

    Each part must have the type VC-DI-ECDSA's ``parseDerivedProofValue`` gives it:
    ``base_signature`` and ``public_key`` byte strings, ``signatures`` an array of byte
    strings, the compressed label map a map of unsigned integers to byte strings and
    ``mandatory_indexes`` an array of unsigned integers. Anything else raises
    :class:`ProofValueMalformed` here, before :meth:`EcdsaSdProofSuite.verify` uses it.
    The holder chooses these bytes, and no key is needed to send them, so a wrong type
    would otherwise surface as a bare ``TypeError`` / ``AttributeError`` that aborts a
    whole ``verify_many`` batch.

    The label map must also be injective. Two blank nodes of the disclosed document
    mapped to one HMAC label are one node to the signature and two to the JSON that
    every check after it reads, so a holder could describe one signed node (a status
    entry) in two places. A genuine derived proof never repeats a label: the disclosed
    blank nodes are distinct nodes of the issuer's document."""
    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
    if not isinstance(base_signature, bytes):
        raise ProofValueMalformed("derived proof base signature must be a byte string")
    if not isinstance(public_key, bytes):
        raise ProofValueMalformed("derived proof public key must be a byte string")
    if not isinstance(signatures, list) or not all(
            isinstance(sig, bytes) for sig in signatures):
        raise ProofValueMalformed("derived proof signatures must be an array of byte strings")
    if not isinstance(compressed_map, dict) or not all(
            _is_index(key) and isinstance(value, bytes)
            for key, value in compressed_map.items()):
        raise ProofValueMalformed(
            "derived proof label map must map unsigned integers to byte strings")
    if not isinstance(mandatory_indexes, list) or not all(
            _is_index(index) for index in mandatory_indexes):
        raise ProofValueMalformed(
            "derived proof mandatory indexes must be an array of unsigned integers")
    if len(set(compressed_map.values())) != len(compressed_map):
        raise ProofValueMalformed(
            "derived proof label map gives two blank nodes the same label")
    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
224
225
226
227
228
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
231
232
233
234
235
236
237
238
239
240
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, defaulting to its default_resolver), or pinned with holder_key_jwk. The resolver also serves the embedded credentials' issuers. Before the holder key is resolved, verify applies the algorithm allow-list, the crit check and the same explicit-typing and SD-JWT-marker tests as the VC-JWT lane to the presentation (#291).

VerifiedPresentation dataclass

A successfully verified VP-JWT.

Source code in src/openvc/proof/vp_jwt.py
51
52
53
54
55
56
57
@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
 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
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
class VpJwtProofSuite:
    """Sign and verify VP-JWT holder presentations."""

    def __init__(self, *, leeway_s: int = DEFAULT_LEEWAY_S) -> None:
        self._leeway = leeway_seconds(leeway_s)       # fail closed, up front (0..MAX_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)
        return self._holder_and_kid(header, payload)

    @staticmethod
    def _holder_and_kid(
        header: dict[str, Any], payload: dict[str, Any],
    ) -> tuple[str | None, str | None]:
        vp = payload.get("vp")
        holder = payload.get("iss") or (vp.get("holder") if isinstance(vp, dict) else None)
        kid = header.get("kid")
        if kid is not None and not isinstance(kid, str):     # as VcJwtProofSuite.peek_issuer
            raise MalformedToken("kid must be a string")
        return (holder if isinstance(holder, str) else None), 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*. *resolver* defaults to
        :func:`openvc.verify.default_resolver`, as in ``verify_credential``. A pinned
        holder key never goes through it, but the embedded credentials' issuers
        still do.

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

        A VP with no ``verifiableCredential`` (VCDM allows one, e.g. DID Auth) verifies
        with an empty *credentials* tuple — a caller that needs a credential must check
        for one (:func:`openvc.openid4vp.verify_vp_token` does). A
        ``verifiableCredential`` that is neither a credential nor an array (a number,
        a bool, ``null``) raises ``ClaimsInvalid``.

        **Nested SD-JWT VCs.** An embedded SD-JWT VC (bare, or inside an
        ``EnvelopedVerifiableCredential``) that carries a Key Binding JWT has it
        verified against **this** presentation's *audience* and *nonce* — whatever
        ``audience``/``nonce`` a passed ``policy`` holds — so its result's
        ``key_bound=True`` means bound to this exchange. A KB-JWT made for another
        verifier or nonce fails the whole presentation (``ClaimsInvalid``). One with no
        KB-JWT reports ``key_bound=False``: the VP signature authenticates the presenter,
        never the credential's ``cnf`` key. If you need that, pass
        ``policy=VerificationPolicy(require_key_binding=True)``, or use
        *require_holder_binding*."""
        if not audience or not nonce:
            raise ClaimsInvalid("VP-JWT verify requires a non-empty audience and nonce")

        # As the VC-JWT lane, in the same order: algorithm allow-list, `crit`, then explicit
        # typing — a JWS typed (or built) as an SD-JWT issuer JWT is not a VP-JWT (#291).
        # All before any key is resolved; verify_compact repeats the first two on the
        # signature path.
        header_peek, payload_peek, _, _ = parse_compact(vp_jwt)
        alg = header_peek.get("alg")
        if not isinstance(alg, str) or alg not in ALLOWED_ALGS:
            raise UnsupportedAlgorithm(f"algorithm {safe_repr(alg)} is not permitted")
        reject_unknown_crit(header_peek)
        _reject_sd_jwt_typ(header_peek)
        _reject_sd_jwt_payload(payload_peek)

        # the resolver serves the holder key (unless pinned) and the cascade, which
        # resolves every embedded credential's issuer even when the holder key IS
        # pinned: default it like verify_credential does (batch_resolvers needs one)
        if 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(header_peek, payload_peek, 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
        # VCDM: `verifiableCredential` is optional (a VP with none, e.g. DID Auth, stays
        # valid here), else one credential or an array. Any other shape — a number, a
        # bool, an explicit null — is a malformed presentation, not an empty one.
        raw = vp.get("verifiableCredential", [])
        if isinstance(raw, (str, dict)):
            entries = [raw]
        elif isinstance(raw, list):
            entries = raw
        else:
            raise ClaimsInvalid(
                f"vp.verifiableCredential must be a credential or an array, "
                f"not {type(raw).__name__}")
        # 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)
        # A nested SD-JWT VC's KB-JWT, if any, must be bound to THIS presentation's
        # aud + nonce — else key_bound=True would vouch for a holder proof made for
        # another verifier. Set after the copy, so no passed kwarg can widen it.
        cascade_kwargs["_kb_binding"] = (audience, nonce)
        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 {safe_repr(result.subject)} 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, header: dict[str, Any], payload: dict[str, Any], resolver: Any,
    ) -> dict[str, Any]:
        holder, kid = self._holder_and_kid(header, payload)
        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: a present exp/nbf/iat must be a
        # finite number (null included), and a future iat is rejected. No waiver here —
        # policy.require_not_expired reaches only the embedded credentials.
        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 {safe_repr(aud)} != 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
68
69
70
71
72
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)
    return self._holder_and_kid(header, payload)

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
 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
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. resolver defaults to :func:openvc.verify.default_resolver, as in verify_credential. A pinned holder key never goes through it, but the embedded credentials' issuers still do.

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.

A VP with no verifiableCredential (VCDM allows one, e.g. DID Auth) verifies with an empty credentials tuple — a caller that needs a credential must check for one (:func:openvc.openid4vp.verify_vp_token does). A verifiableCredential that is neither a credential nor an array (a number, a bool, null) raises ClaimsInvalid.

Nested SD-JWT VCs. An embedded SD-JWT VC (bare, or inside an EnvelopedVerifiableCredential) that carries a Key Binding JWT has it verified against this presentation's audience and nonce — whatever audience/nonce a passed policy holds — so its result's key_bound=True means bound to this exchange. A KB-JWT made for another verifier or nonce fails the whole presentation (ClaimsInvalid). One with no KB-JWT reports key_bound=False: the VP signature authenticates the presenter, never the credential's cnf key. If you need that, pass policy=VerificationPolicy(require_key_binding=True), or use require_holder_binding.

Source code in src/openvc/proof/vp_jwt.py
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
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
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*. *resolver* defaults to
    :func:`openvc.verify.default_resolver`, as in ``verify_credential``. A pinned
    holder key never goes through it, but the embedded credentials' issuers
    still do.

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

    A VP with no ``verifiableCredential`` (VCDM allows one, e.g. DID Auth) verifies
    with an empty *credentials* tuple — a caller that needs a credential must check
    for one (:func:`openvc.openid4vp.verify_vp_token` does). A
    ``verifiableCredential`` that is neither a credential nor an array (a number,
    a bool, ``null``) raises ``ClaimsInvalid``.

    **Nested SD-JWT VCs.** An embedded SD-JWT VC (bare, or inside an
    ``EnvelopedVerifiableCredential``) that carries a Key Binding JWT has it
    verified against **this** presentation's *audience* and *nonce* — whatever
    ``audience``/``nonce`` a passed ``policy`` holds — so its result's
    ``key_bound=True`` means bound to this exchange. A KB-JWT made for another
    verifier or nonce fails the whole presentation (``ClaimsInvalid``). One with no
    KB-JWT reports ``key_bound=False``: the VP signature authenticates the presenter,
    never the credential's ``cnf`` key. If you need that, pass
    ``policy=VerificationPolicy(require_key_binding=True)``, or use
    *require_holder_binding*."""
    if not audience or not nonce:
        raise ClaimsInvalid("VP-JWT verify requires a non-empty audience and nonce")

    # As the VC-JWT lane, in the same order: algorithm allow-list, `crit`, then explicit
    # typing — a JWS typed (or built) as an SD-JWT issuer JWT is not a VP-JWT (#291).
    # All before any key is resolved; verify_compact repeats the first two on the
    # signature path.
    header_peek, payload_peek, _, _ = parse_compact(vp_jwt)
    alg = header_peek.get("alg")
    if not isinstance(alg, str) or alg not in ALLOWED_ALGS:
        raise UnsupportedAlgorithm(f"algorithm {safe_repr(alg)} is not permitted")
    reject_unknown_crit(header_peek)
    _reject_sd_jwt_typ(header_peek)
    _reject_sd_jwt_payload(payload_peek)

    # the resolver serves the holder key (unless pinned) and the cascade, which
    # resolves every embedded credential's issuer even when the holder key IS
    # pinned: default it like verify_credential does (batch_resolvers needs one)
    if 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(header_peek, payload_peek, 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
    # VCDM: `verifiableCredential` is optional (a VP with none, e.g. DID Auth, stays
    # valid here), else one credential or an array. Any other shape — a number, a
    # bool, an explicit null — is a malformed presentation, not an empty one.
    raw = vp.get("verifiableCredential", [])
    if isinstance(raw, (str, dict)):
        entries = [raw]
    elif isinstance(raw, list):
        entries = raw
    else:
        raise ClaimsInvalid(
            f"vp.verifiableCredential must be a credential or an array, "
            f"not {type(raw).__name__}")
    # 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)
    # A nested SD-JWT VC's KB-JWT, if any, must be bound to THIS presentation's
    # aud + nonce — else key_bound=True would vouch for a holder proof made for
    # another verifier. Set after the copy, so no passed kwarg can widen it.
    cascade_kwargs["_kb_binding"] = (audience, nonce)
    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 {safe_repr(result.subject)} is not the holder "
                    f"{holder!r} (holder binding required)")

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