Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# The bundled JSON-LD contexts are integrity-pinned: openbadgeslib/ob3/contexts/
# __init__.py records a SHA-256 per file, and that digest is the control which
# makes the fail-closed context allowlist meaningful (the bundled document
# decides what a Data Integrity proof covers). Git's EOL conversion would
# rewrite them to CRLF on a Windows checkout, so the bytes on disk would stop
# being the bytes that were hashed and the provenance could not be checked at
# all — which is exactly what the gate exists to prevent (#266, #274).
#
# `-text` disables EOL conversion for these files on every platform. Treat any
# other byte-pinned artifact the same way.
openbadgeslib/ob3/contexts/*.json -text
55 changes: 55 additions & 0 deletions Changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,61 @@ Newest first. Dates are ISO 8601 (YYYY-MM-DD).

A security & correctness hardening pass (audit-bot).

- fix(build): stop git's EOL conversion from defeating the bundled contexts'
recorded SHA-256 provenance on Windows. Without a `.gitattributes` entry git
checks the JSON-LD contexts out with CRLF on a Windows clone, so the bytes on
disk are not the bytes the digests in ob3/contexts/__init__.py cover — the
control that makes the fail-closed context allowlist meaningful could not be
evaluated on that platform at all, and the `test (windows)` CI leg had been
red since the gate landed (which also blocks releases: `publish` is
`needs: test`). The contexts are now marked `-text`, and a gate asserts that
attribute stays. Wheel/sdist installs were never affected (#274).
- fix(keys): a failed PEM load lands in the library's exception taxonomy on
every supported `cryptography`, and the floor moves to >=45. cryptography
v47 changed the contract — loading a key of an unsupported algorithm now
raises `UnsupportedAlgorithm`, which is NOT a ValueError — while
`KeyRSA`/`KeyECC.read_private_key`/`read_public_key` and the Ed25519 loaders
caught nothing at all, so a corrupt or unsupported key PEM escaped as a raw
crypto traceback (the `Badge` entry point was already guarded; these direct
callers were not). All PEM loading now goes through one guarded helper that
maps both contracts onto `PrivateKeyReadError` / `PublicKeyReadError`,
keeping the original exception as `__cause__`; `detect_key_type` /
`ec_curve_from_pem` stay total. `KeyRSA.generate_keypair` maps the v48
1024-bit floor to `GenPrivateKeyError` instead of a raw ValueError. The
floor is 45 (openvc-core's own pin, so the extras resolve one range) rather
than 47: both contracts are handled and tested, so requiring an April-2026
release would buy nothing (#244).
- feat(eudi): build the OpenID4VCI credential configuration entry for an Open
Badge issued as SD-JWT VC. `badge_credential_configuration()` returns the
`credential_configurations_supported` entry a wallet reads to request the
badge — `format: dc+sd-jwt`, the `vct`, signing/proof algorithms (ES256, the
HAIP floor), `cryptographic_binding_methods_supported`, `claims` and
`display` — derived from the same `badge_type_metadata()` document the
issuer serves at that `vct`, so the advertised claim set and the served Type
Metadata cannot drift apart (the drift makes a wallet reject the credential
or display nothing). Pure-Python, no `[eudi]` extra. The enclosing
`/.well-known/openid-credential-issuer` document, the Credential Offer and
the endpoints stay the application's: they are deployment policy, one per
tenant (#271).
- feat(eudi): SD-JWT VC badges are revocable, via the IETF OAuth Token Status
List. `issue_badge_sd_jwt(..., status=...)` takes the reference
`openvc.status.issue.build_token_status_reference(uri=…, index=…)` builds and
emits it as a `status` claim that is **always disclosed** — never selectively
disclosable, whatever `disclosable=` says, since a holder able to withhold
the pointer could present a revoked badge as an unrevokable one. On the
verify side both trust paths (pinned key and x5c) now take
`require_status` / `resolve_status_list_token`: a badge whose bit says
revoked or suspended raises `EudiError`, and every way of not knowing the
status fails closed — an unresolvable, wrongly-signed, expired or
URL-swapped list raises rather than reading as "not revoked". The new
`status_list_token_resolver(pubkey_pem=…)` builds the safe default resolver
(HTTPS fetch + openvc-core's `verify_status_list_token`, including the IETF
anti-swap `sub` check). Encoding, signing and packing stay openvc-core's;
the `[eudi]` floor moves to >=1.23. This closes the #226 irrevocability
boundary for this track. Two formats, two lists: a credential carrying a
W3C `credentialStatus` (Bitstring) is still refused at SD-JWT issuance
rather than silently mapped onto a different wire format — a badge issued
on both tracks needs an index in each list (#245, #270).
- security(ob2): make the fetched copy of a HostedBadge authoritative for the
whole document. `_verify_hosted` reconciled only `id`, `recipient`, `badge`,
`issuedOn` and `expires` and then kept the copy baked into the image, so
Expand Down
2 changes: 1 addition & 1 deletion environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dependencies:
# input, so CVE-2026-32597 (crit validation) and CVE-2026-48525 (b64=false
# payload DoS) are on a reachable path.
- pyjwt>=2.13
- cryptography>=42 # required by pyjwt[crypto]
- cryptography>=45 # required by pyjwt[crypto]; openvc-core's core pin too
- defusedxml>=0.7 # hardened XML parsing of untrusted SVG badges

# Development / testing
Expand Down
74 changes: 50 additions & 24 deletions openbadgeslib/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
# ``key_to_pem`` now takes only ``cryptography`` key objects and PEM bytes/str.

from typing import Any, Optional, Tuple, Union, cast
from .errors import PublicKeyReadError, UnknownKeyType
from .errors import (
GenPrivateKeyError, PrivateKeyReadError, PublicKeyReadError, UnknownKeyType)
from cryptography.exceptions import UnsupportedAlgorithm
from cryptography.hazmat.primitives import serialization as _crypto_serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Expand All @@ -38,6 +40,32 @@
import logging
logger = logging.getLogger(__name__)

# What a failed PEM load raises depends on the `cryptography` version: up to v46
# an unsupported key algorithm came back as ValueError; **v47 changed the
# contract** to UnsupportedAlgorithm (which is NOT a ValueError). Our floor
# spans that change, so both are caught in one place and mapped onto the
# library's own taxonomy — a caller must never have to know which cryptography
# it resolved, and a raw crypto exception must never escape as a traceback.
_PEM_LOAD_ERRORS = (ValueError, TypeError, UnsupportedAlgorithm)


def _load_private_pem(data: bytes) -> Any:
"""Load a private key PEM, mapping any load failure to PrivateKeyReadError."""
try:
return _crypto_serialization.load_pem_private_key(data, password=None)
except _PEM_LOAD_ERRORS as exc:
raise PrivateKeyReadError(
'Unable to read private key PEM: %s' % exc) from exc


def _load_public_pem(data: bytes) -> Any:
"""Load a public key PEM, mapping any load failure to PublicKeyReadError."""
try:
return _crypto_serialization.load_pem_public_key(data)
except _PEM_LOAD_ERRORS as exc:
raise PublicKeyReadError(
'Unable to read public key PEM: %s' % exc) from exc


class KeyType(Enum):
RSA = 'RSA 2048'
Expand Down Expand Up @@ -100,20 +128,26 @@ def __init__(self, key_size: int = 2048) -> None:

def generate_keypair(self) -> Tuple[bytes, bytes]:
""" Generate a RSA Key, returning in PEM Format """
self.priv_key = rsa.generate_private_key(
public_exponent=65537, key_size=self._key_size)
try:
self.priv_key = rsa.generate_private_key(
public_exponent=65537, key_size=self._key_size)
except (ValueError, UnsupportedAlgorithm) as exc:
# cryptography v48 enforces a 1024-bit floor on key_size; below it
# (and on any other refused parameter) this must be a library error,
# not a raw traceback out of the CLI.
raise GenPrivateKeyError(
'Unable to generate a %d-bit RSA key: %s'
% (self._key_size, exc)) from exc
self.pub_key = self.priv_key.public_key()
return self.get_priv_key_pem(), self.get_pub_key_pem()

def read_private_key(self, key_pem: Any = None) -> None:
""" Read the private key from param in PEM format """
self.priv_key = _crypto_serialization.load_pem_private_key(
_pem_bytes(key_pem), password=None)
self.priv_key = _load_private_pem(_pem_bytes(key_pem))

def read_public_key(self, key_pem: Any = None) -> None:
""" Read the public key from param in PEM format """
self.pub_key = _crypto_serialization.load_pem_public_key(
_pem_bytes(key_pem))
self.pub_key = _load_public_pem(_pem_bytes(key_pem))

def get_priv_key_pem(self) -> bytes:
return _private_pem(self.priv_key)
Expand All @@ -137,13 +171,11 @@ def generate_keypair(self) -> Tuple[bytes, bytes]:

def read_private_key(self, key_pem: Any = None) -> None:
""" Read the private key from param in PEM format """
self.priv_key = _crypto_serialization.load_pem_private_key(
_pem_bytes(key_pem), password=None)
self.priv_key = _load_private_pem(_pem_bytes(key_pem))

def read_public_key(self, key_pem: Any = None) -> None:
""" Read the public key from param in PEM format """
self.pub_key = _crypto_serialization.load_pem_public_key(
_pem_bytes(key_pem))
self.pub_key = _load_public_pem(_pem_bytes(key_pem))

def get_priv_key_pem(self) -> bytes:
return _private_pem(self.priv_key)
Expand All @@ -155,7 +187,7 @@ def get_pub_key_pem(self) -> bytes:
def _load_ed25519_private_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519PrivateKey:
if key_pem is None:
raise UnknownKeyType('No Ed25519 private key PEM provided')
key = _crypto_serialization.load_pem_private_key(_pem_bytes(key_pem), password=None)
key = _load_private_pem(_pem_bytes(key_pem))
if not isinstance(key, Ed25519PrivateKey):
raise UnknownKeyType('PEM is not an Ed25519 private key')
return key
Expand All @@ -164,7 +196,7 @@ def _load_ed25519_private_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519Pr
def _load_ed25519_public_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519PublicKey:
if key_pem is None:
raise UnknownKeyType('No Ed25519 public key PEM provided')
key = _crypto_serialization.load_pem_public_key(_pem_bytes(key_pem))
key = _load_public_pem(_pem_bytes(key_pem))
if not isinstance(key, Ed25519PublicKey):
raise UnknownKeyType('PEM is not an Ed25519 public key')
return key
Expand Down Expand Up @@ -249,7 +281,7 @@ def public_jwk_from_pem(pubkey_pem: Union[str, bytes]) -> dict[str, Any]:
import json
from jwt.algorithms import ECAlgorithm, OKPAlgorithm, RSAAlgorithm
try:
pub = _crypto_serialization.load_pem_public_key(_pem_bytes(pubkey_pem))
pub = _load_public_pem(_pem_bytes(pubkey_pem))
if isinstance(pub, rsa.RSAPublicKey):
jwk_json = RSAAlgorithm.to_jwk(pub)
elif isinstance(pub, ec.EllipticCurvePublicKey):
Expand Down Expand Up @@ -277,12 +309,9 @@ def ec_curve_from_pem(pem_data: Union[str, bytes]) -> Optional[str]:
fixes the JOSE algorithm.
"""
data = _pem_bytes(pem_data)
for load in (
lambda: _crypto_serialization.load_pem_public_key(data),
lambda: _crypto_serialization.load_pem_private_key(data, password=None),
):
for load in (_load_public_pem, _load_private_pem):
try:
key = load()
key = load(data)
except Exception:
continue
if isinstance(key, (ec.EllipticCurvePublicKey,
Expand All @@ -302,12 +331,9 @@ def detect_key_type(pem_data: Union[str, bytes]) -> 'KeyType':
"""
data = _pem_bytes(pem_data)
key: Any = None
for load in (
lambda: _crypto_serialization.load_pem_public_key(data),
lambda: _crypto_serialization.load_pem_private_key(data, password=None),
):
for load in (_load_public_pem, _load_private_pem):
try:
key = load()
key = load(data)
break
except Exception:
continue
Expand Down
Loading
Loading