OpenBadgesLib - Changelog
=========================

Newest first. Dates are ISO 8601 (YYYY-MM-DD).


* v4.0.0 - 2026-07-22

  Grouped breaking release (#170) — freeze the declared public contract and
  unify the CLIs — shipped together with the Round-2 roadmap work: close the
  revocation/trust gaps the deep-logic analysis surfaced, test the fail-closed
  guards, and speed up batch issuance and verification. Breaking changes first,
  then the rest of the cycle.

  - fix(cli)!: unify every console tool on the 0/1/2 exit-code contract (#233).
    The 19 `sys.exit(-1)` sites (which exited 255) now exit 1, and the verifier
    applies the same status in human mode as under --json: 1 on any failure, 2
    when the signature is valid but the issuer is not anchored (an OB2
    badge-embedded key or a self-asserted did:key), and 0 only when the badge is
    valid AND trusted. A partial batch sign or publish, and a stale/missing
    artifact under `publish --check-live`, now exit 2 in human mode too (they
    exited 1 before). BREAKING: a wrapper that keyed on 255, or that treated a
    valid-but-untrusted badge's exit 0 as success, must be updated.
  - feat(cli): add a unified `openbadges <command>` front-end and centralise the
    shared flags (#234). A new `openbadgeslib/cli_common.py` provides argparse
    parent parsers for -c/--config, -d/--debug, --json and -v/--version, which
    all five build_parser()s now compose — so the flags are defined once with
    consistent help. openbadges-publish gains -d/--debug (it had none) and
    openbadges-init gains argparse (hence -v/--version, and -h now exits 0 via
    argparse). The new `openbadges` entry point dispatches to init/keygen/sign/
    verify/publish, each of which stays installed under its own openbadges-<name>
    script; a command owns everything after its name, so `openbadges sign ...`
    behaves exactly like `openbadges-signer ...`. Minor breaking: openbadges-init
    with a bad/missing argument now exits 2 (argparse), not 1.
  - feat(cli): add `openbadges status [ID]`, the sixth subcommand promised by
    the unified front-end (#170). It is the read-only registry query that until
    now was reachable only as `openbadges-publish -V 3 --list/--status`: with no
    ID it tabulates every issued OB3 credential and its state, with one (a jti
    or a recipient email) it prints that credential's full record including the
    revocation reason. Auditing a credential is a read, so it no longer requires
    typing a publish verb, nor `-V 3` — status lists are OB3-only by
    construction. The query itself is not duplicated: the command and publish's
    flags both call the shared `query_ob3()`, which now takes plain arguments
    instead of a parsed argparse Namespace. `--list`/`--status` keep working
    unchanged. Unlike the other five commands this one has no standalone
    `openbadges-status` script — those aliases exist only because they predate
    the front-end.
  - refactor(ob1)!: consolidate the OB1 compatibility surface (#235). The OB1
    legacy name table (11 names -> leaf module) is single-sourced in
    openbadgeslib/_ob1_api.py instead of being duplicated between the top-level
    package and openbadgeslib.ob1. The three top-level shim modules —
    openbadgeslib.badge, openbadgeslib.signer and openbadgeslib.verifier — are
    removed. OpenBadges 1.0 stays fully supported and reachable via
    openbadgeslib.ob1.* (e.g. `from openbadgeslib.ob1 import Signer`) and the
    unprefixed openbadgeslib.Signer / .Badge / … re-exports, both of which still
    emit the steering DeprecationWarning. BREAKING: `from openbadgeslib.badge
    import ...` (and .signer/.verifier) no longer resolve — repoint them to
    `openbadgeslib.ob1.<module>`.
  - refactor(keys)!: drop the last pycryptodome / python-ecdsa soft-import
    (#170). Both libraries stopped being dependencies in the 3.7 port (#167),
    but `key_to_pem` kept a soft-import shim that still accepted a *live*
    pycryptodome RSA or python-ecdsa key object. That shim (`_legacy_key_to_pem`)
    is removed, along with the `Crypto.*`/`ecdsa.*` mypy overrides: `key_to_pem`
    now takes only `cryptography` key objects and PEM bytes/str, raising
    `UnknownKeyType` otherwise. Key *files* are unaffected — they were already
    plain PEM and round-trip unchanged. BREAKING: pass a `cryptography` key
    object or PEM instead of a live pycryptodome/python-ecdsa object.

  - fix(ob3): bundle and reference the OB v3p0 extensions context so the
    `credentialSchema` type (`1EdTechJsonSchemaValidator2019`, emitted since
    v3.6.0) resolves to a defined JSON-LD term. Without it the term was
    undefined: the official 1EdTech OB3 validator rejected every issued
    credential ("Undefined JSON-LD term") and canonicalization mapped the type
    to a base-relative IRI, so the Data Integrity proof no longer verified. The
    offline JSON-Schema gate did not catch this (it checks the string, not
    JSON-LD term resolution). Issued credentials now carry a third `@context`
    entry (the official schema pins only the first two and allows extras); the
    context is pinned in the fail-closed allowlist and covered by the drift
    watchdog (#239).
  - security(config): close the bypass in the userinfo guard below: checking
    `urlsplit(...).netloc` only catches a credential whose `@` lands inside the
    authority, and a password holding a `/`, `?` or `#` pushes it out. Both
    layers accepted `https://user:12345/x@host/` — which `did_web_from_url`
    then turned into the issuer id `did:web:user%3A12345:x%40host`, carried by
    every signed credential — and `https://user:secret/x@host/`, which surfaced
    the password through urllib's own `Port could not be cast to integer value
    as 'secret'` (a message the redaction added below could not reach, since
    urllib raises it). Both layers now refuse an `@` or `%40` *anywhere* in a
    URL-shaped value, and `did_web_from_url` wraps the port parse and quotes
    nothing at all. Values with no authority — `[issuer] email`, local paths —
    still accept an `@`. BREAKING (narrow): a `publish_url` whose path contains
    an `@`, e.g. `https://host/@name/`, is now refused; once parsed it is
    indistinguishable from one hiding a credential (#255).
  - security(config): reject a `[issuer]` / `[badge_*]` URL that embeds
    userinfo (`https://user:password@host/`) when the config is loaded. Those
    URLs are public identifiers: `publish_url` is printed by every publish
    command, `urljoin`ed into the ids written to the publicly served
    organization.json / badge.json / key.json (urljoin preserves the
    `user:pass@`), turned into the issuer's did:web, and used to build the
    `credentialStatus` list URLs signed into every OB3 credential — so a
    password there leaked into user-visible output, files served on the open
    web and the badges delivered to recipients alike. Only one narrow path
    guarded against it (did:web derivation, reached solely by
    `[issuer] did = auto`), and that guard echoed the password back in its own
    error for a non-https URL. The check now lives once in
    `ConfParser.read_conf()` — after `${...}` interpolation, so a reference
    cannot smuggle a credential past it — and every consumer (OB1/OB2/OB3
    publishing, DID derivation, status lists) inherits it; the ConfigError
    names the offending section and key but never the value. `did_web_from_url`
    keeps its own guard as defence in depth, with the userinfo check moved
    ahead of the scheme and host checks and their messages redacted. Behaviour
    change: a config that protected a staging host with credentials in a
    published URL no longer loads — drop the userinfo and use an allow-list or
    a token the library never sees. `[smtp] username` / `password` are
    untouched; they never leave the issuer's machine (#252).
  - security(eudi): document and pin the X.509 / eIDAS trust boundary for
    SD-JWT VC verification. All x5c chain validation is delegated to openvc-core
    (path building, temporal validity, SAN<->iss binding); openbadgeslib only
    guarantees the envelope (the x5c header is present, so the anchors are never
    bypassed by a DID fallback — fail-closed). Crucially, certificate revocation
    (CRL/OCSP) is NOT checked by either layer — a revoked-but-unexpired leaf
    still verifies — so a deployment must apply revocation out of band. The
    division of responsibility is spelled out on `verify_badge_sd_jwt` and the
    Security Model wiki page; new boundary tests assert openvc-core rejects an
    expired/not-yet-valid leaf and pin the revocation gap, and the eudi
    floor/latest CI job now runs them so a drift in the delegate's behaviour
    turns the build red (#236).
  - security(ob3): with `--check-status`, verify the status list's OWN JWT-VC
    signature by default and bind its issuer to the badge's, so a compromised
    status host can no longer silently un-revoke a credential. The badge's
    verification key is reused (publish signs the list with it), or the issuer
    DID is resolved. `verify(check_status=True)` gains `verify_status_list=True`
    by default; the verifier CLI gains `--no-verify-status-list` to opt out for
    issuers that serve an unsigned list (#213).
  - security(ob3): `OB3LdpVerifier` fails closed when it has no pinned key and
    the credential's issuer is not a DID — the proof's `verificationMethod`
    cannot be bound to a non-DID issuer, so resolving and trusting it would
    prove internal consistency, not identity (#214).
  - security(ob3): the endorsement validity window fails closed on a malformed
    `validFrom`/`validUntil` (it previously swallowed the parse error and
    accepted the endorsement, unlike every other window) (#218).
  - fix(ob2, ob3): apply a 60-second clock-skew leeway to every validity window
    (vc `validFrom`/`validUntil`, the status list window, OB2 `issuedOn`/
    `expires`), so a slightly-fast issuer clock no longer false-rejects a
    freshly issued credential (#217).
  - perf(ob3, ob1): load the private-key object once per signer instead of
    re-parsing the PEM on every signature (twice per badge on the JWT-VC path).
    An RSA batch of 1000 badges drops from ~86 s to ~1.5 s; the OB1/OB2 JWS
    path reuses the already-loaded key too (#215).
  - perf(baking): build the baked PNG with a single `b''.join` instead of
    quadratic `bytes` concatenation — ~200x faster on images written with many
    small IDAT chunks (Photoshop/libpng), e.g. ~100 ms -> ~0.5 ms for 6.6 MB
    (#216).
  - test: add negative tests for the fail-closed rejection guards that had none
    — OB2 hosted `startsWith`/`allowedOrigins` reject sides, the OB3 status
    anti-gzip-bomb cap and negative index, the LDP verificationMethod binding,
    and a future JWT `nbf` (#219).
  - ci: the nightly conformance job now fails if a 1EdTech validator does not
    come up or if no conformance test actually ran (all-skipped), instead of
    reporting green, and files (or updates) an issue on a scheduled failure
    (#220).
  - ci: install the `[ldp-sd]` extra explicitly and turn the ecdsa-sd-2023
    selective-disclosure test skip into a hard failure under CI, so an
    openvc-core packaging change can no longer silently disable that suite
    (#221).
  - perf(ob3): add an opt-in `CachingDownloader` so verifying a batch of
    credentials from one issuer fetches each did:web document and status list
    once rather than per credential. `OB3LdpVerifier` also memoizes each
    resolved `verificationMethod` per instance, and `OB3Verifier.verify` /
    `check_status` accept a `download=` fetcher to thread it through (#228).
  - perf(ob3): tag the bundled JSON-LD `@context`s `static` in the pyld
    document loader, priming pyld's process-global resolved-context cache so
    term definitions are not rebuilt on every canonicalization (~1.6x faster
    Data Integrity sign/verify); per-call `extra_contexts` stay untagged so a
    changing context is never served stale (#229).
  - feat: extract a programmatic verification facade — `openbadgeslib.verify`
    with `verify_badge(data, ob_version, *, pubkey_pem | resolve_did,
    expected_recipient, check_status, …) -> VerifyResult` (fields `valid`,
    `trusted`, and the decoded `credential`/`assertion`). It runs the whole
    verification — token extraction, JWT-VC/LDP auto-detection, issuer-DID
    resolution, trust classification, signature/status — doing no I/O; an
    invalid badge is a result, not an exception. `openbadges-verifier` is now a
    thin presenter over it (byte-identical output). Part of #222.
  - feat: extract a programmatic publication facade —
    `openbadgeslib.ob3.publish_ob3(conf, output, *, revoke | suspend |
    unsuspend, reason, badge, check_live) -> PublishResult`. It runs the OB3
    issuer-artefact regeneration (the did:web document, the signed Bitstring
    Status List credentials, the opt-in SD-JWT VC Type Metadata) and the
    status-registry revoke/suspend/unsuspend transaction — writing the
    artefacts but doing no printing — and raises `PublishError` /
    `CredentialNotFound` / `AmbiguousCredential` instead of exiting.
    `openbadges-publish -V 3` is now a thin presenter over it (byte-identical
    output). Completes #222.
  - fix(issue): move the version-neutral badge config model (`Badge`,
    `BadgeImgType`) out of the legacy `ob1` package into
    `openbadgeslib.badge_model` (re-exported from `ob1.badge` for
    compatibility). `create_from_conf` now reads the OpenBadges 1.0-only
    `verify_key`/`criteria` keys optionally, so issuing an OB3 badge that omits
    them no longer blows up with a raw `KeyError('verify_key')`; a missing
    required key surfaces as `IssuanceError` through the `issue_*` entry points,
    and a missing image raises a typed error instead of `print` + bare
    `IOError` (#223).
  - refactor(errors): consolidate the exception hierarchy so a single
    `except LibOpenBadgesException` traps every library error. `IssuanceError`
    and `DecompressionLimitExceeded` are re-anchored under the root (still
    importable from `openbadgeslib.issue` / `openbadgeslib.baking`); the config
    helpers' bare `ValueError`s become a new `ConfigError` and the signers'
    unsupported-algorithm error becomes `UnsupportedAlgorithm` — both also
    subclass `ValueError` so `except ValueError` callers keep working.
    `_write_badge_and_log` reads the `[logs]` path inside its `try`, so a missing
    `[logs]` section is reported instead of crashing with a traceback after the
    badge is already on disk. Adds singular aliases (`KeyGenException`,
    `SignerException`, `VerifierException`) for the plural base families (#224).
  - refactor(ob2, ob3): deduplicate the shared JSON helpers (`_iso`,
    `_parse_iso`, `_as_dict`, `_require`) into an internal
    `openbadgeslib._jsonmodel`. The two copies had drifted: OB2's `_iso`
    serialised a naive datetime as *local* time (`astimezone` on a naive value)
    while OB3 anchored it to UTC, so the same timestamp emitted a different
    instant in OB2 than in OB3; the single implementation keeps OB3's
    naive=>UTC rule (#225).
  - feat(confparser): resolve config through typed dataclasses with centralized
    defaults. `load_config(path) -> ConfigParser` is the library entry point
    (raises `ConfigError`); `read_config_or_exit` is now a thin CLI wrapper over
    it (and the flagship example uses `load_config`). New `IssuerConfig` /
    `BadgeSectionConfig` dataclasses (extending the `OB3StatusConfig` pattern)
    resolve the `[issuer]` and badge sections and their defaults once (issuer id,
    `criteria_narrative`->`criteria` fallback, hosted-assertions base), and a
    single `DEFAULT_KEY_TYPE` + `resolve_key_type()` centralises the key_type
    name->KeyType mapping the keygenerator used to inline (#225).
  - security(ob3): warn loudly when `openbadges-publish -V 3` regenerates a
    revocable badge's status lists without a `validUntil` bound (no
    `status_validity_days` set). An unbounded list has no anti-replay freshness —
    an authentic older copy with fewer revocations passes the window unchecked,
    so a revoked credential could be silently resurrected. `PublishResult` gains
    a `no_validity_bound` list (surfaced in the CLI's human output and `--json`),
    and the config example documents the republication cadence (#227).
  - security(ob3): SD-JWT VC (EUDI) issuance no longer silently drops a
    credential's `credentialStatus`. Because SD-JWT VC status carriage/checking
    is not wired yet (both verify paths hard-set `require_status=False`), an
    SD-JWT VC badge is irrevocable; `issue_badge_sd_jwt` now rejects a credential
    that carries a `credentialStatus` with a clear `EudiError` (before requiring
    the [eudi] extra) rather than issue a badge the issuer believes is revocable.
    `badge_to_sd_jwt_claims` also carries `credentialSubject.identifier` (the
    hashed recipient identities), so a badge whose subject travelled via
    `identifier` (no `id`) keeps a checkable recipient. The irrevocability is
    documented prominently on the issue/verify docstrings (#226).
  - security(util): pin the SSRF-validated IP on the HTTPS connection to defeat
    DNS rebinding. `_assert_public_host` validated the resolved IPs, but
    `opener.open` then re-resolved on its own, so a hostile DNS (TTL 0) could
    answer public during the check and internal (169.254.169.254 / loopback /
    RFC1918) at the real connect. A new `_PinnedHTTPSConnection` re-resolves,
    re-validates and dials the validated IP in one step (keeping the hostname
    for TLS SNI / certificate verification and the Host header) — for the initial
    request and every redirect. Also adds a wall-clock download budget
    (`MAX_DOWNLOAD_SECONDS`, 60 s), since the per-socket `timeout=30` bounds each
    read but not a slow-drip response's total time (#231).
  - ci: harden the release-critical workflows. SHA-pin the actions that run with
    `id-token: write` — `pypa/gh-action-pypi-publish` (PyPI Trusted Publishing)
    and the GitHub Pages `upload-pages-artifact` / `deploy-pages` — so a
    compromised upstream tag cannot publish or deploy under our OIDC identity
    (Dependabot already tracks github-actions and bumps the pins). Add a
    least-privilege `permissions: contents: read` to commit-lint.yml. Add a
    `windows-latest` CI leg plus a monkeypatched test of the `fcntl=None`
    StatusRegistry lock fallback, so the "OS Independent" classifier is actually
    exercised on the non-POSIX path. The leg immediately caught a real
    portability bug it fixes: `openbadges-publish` now records `files_written`
    with forward slashes (a Windows `\` would have broken the artefact URLs built
    via `urljoin`); the POSIX file-mode assertions skip on Windows and the
    ldp-sd hard-fail guard keys on an explicit `OPENBADGES_REQUIRE_LDP_SD` flag
    (set by the main test job) so the core-only Windows leg skips it (#230).
  - refactor: declare the stable public surface (freeze prep for v4). The root
    package gains an explicit `__all__` (the "public contract"), re-exports
    `KeyEd25519` (the recommended OB 3.0 / LDP key) alongside `KeyRSA`/`KeyECC`,
    and exposes the `ob2`/`ob3`/`errors` subpackages and the
    `issue_from_conf`/`verify_badge` facades by name. `openbadgeslib.ob3`
    re-exports the `eudi` SD-JWT VC track (its heavy imports stay lazy).
    `SignResult.credential`/`.assertion` and the `_Ob3Context` fields are typed
    with `TYPE_CHECKING` forward refs instead of `Any` (zero runtime cost,
    correct autocompletion / pdoc). Adds a "Stable API" wiki page documenting
    the stable-vs-internal boundary (#232).
  - build(deps): the [dev] extra depends on gitlint-core instead of the gitlint
    meta-package, so it stops installing vulnerable pins. `gitlint` is an empty
    shim for `gitlint-core[trusted-deps]`, and that extra hard-pins click==8.1.3
    and sh==1.14.3 — PYSEC-2026-2132 (command injection in `click.edit()`, fixed
    in 8.3.3) and CVE-2026-54552 (incomplete privilege drop in sh's `_uid`, fixed
    in 2.2.4), which had been failing the weekly Audit job. Neither is reachable
    here — gitlint never calls `click.edit()`, and it does not import `sh` at all
    unless GITLINT_USE_SH_LIB=1, which this repo never sets — but upstream has
    been dormant since 2023, so the pins would keep the audit red indefinitely.
    gitlint-core declares the same dependencies loosely and provides the same
    `gitlint` console script; the [dev] extra now floors `click>=8.3.3` and
    `sh>=2.2.4` (keeping gitlint-core's non-Windows marker for sh) and caps both
    below the next major. No pip-audit ignores were added: the advisories are
    resolved by upgrading, not suppressed. Library users are unaffected — click
    and sh were only ever development dependencies.
  - build(deps)!: raise the PyJWT floor to >=2.13 and the openvc-core floors to
    >=1.21. The OB3 JWT-VC verifier decodes an untrusted badge with a
    signature-verifying `jwt.decode()`, so the PyJWT 2.12/2.13 advisory batch is
    on a reachable path: RFC 7515 §4.1.11 `crit` validation (2.12.0,
    CVE-2026-32597) and the b64=false unencoded-payload DoS (2.13.0,
    CVE-2026-48525), plus JWK/alg-binding hardening; 2.13.0 is the minimal floor
    carrying all of them. Installing [eudi]/[ldp-sd] already pulled a safe PyJWT
    transitively via openvc-core (whose own floor is >=2.13), so this closes the
    gap for a base, extras-free install. The native `_jws` engine is structurally
    immune (it never calls `jwt.decode`). The [eudi]/[ldp-sd] openvc-core floors
    move from >=1.18 / >=1.13 to >=1.21 per the pin's stated policy (floor at the
    version the suite is tested against): 1.21 keeps in-floor the delegated
    SD-JWT fixes openbadgeslib relies on — the key-binding replay fix that now
    requires a nonce AND audience (1.19.3) and unknown-`crit` rejection on the
    SD-JWT lane (1.20.4). No openbadgeslib code changed: openvc's new typed
    ecdsa-sd errors (ProofValueMalformed/ProofMalformed) subclass ProofError, so
    the existing `except ProofError` in ob3/ldp.py still catches them; the full
    suite passes against openvc-core 1.21.0. BREAKING only for a caller that
    pinned PyJWT <2.13 or openvc-core <1.21 alongside openbadgeslib.
  - security(cli): `openbadges-init` creates config.ini owner-only (0600). It
    is the file that goes on to hold the SMTP password and the paths to the
    private keys, but the copy ran outside the umask block that hardens the
    directories, so it inherited the caller's umask — 0644 on a default shell.
    The 0700 parent contained it; this is the second lock, matching the one
    the keygenerator puts on the private key.


* v3.14.0 - 2026-07-10

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

  - security(eudi): reject an X.509-anchored SD-JWT verification whose issuer
    JWT carries no `x5c` chain, so openvc-core cannot fall back to DID /
    issuer-URL key resolution and bypass the EU trust anchors (#186).
  - security(ob3): reject a Data Integrity credential carrying the unsigned VC
    1.1 aliases `expirationDate`/`issuanceDate` or a legacy `StatusList2021Entry`
    status — the RDF-canonicalized proof cannot cover them, so a holder could
    otherwise un-expire / un-revoke a signed credential (#205).
  - security(ob2): reconcile a hosted badge's `expires` against the authoritative
    fetched copy, so a stripped/extended local `expires` no longer defeats the
    expiration check (#187).
  - security(ob3): read `statusSize` from the BitstringStatusListEntry and fail
    closed on a multi-bit entry, so a revoked/suspended multi-bit credential is
    no longer misreported as valid (#188).
  - security(mail): give `SMTP_SSL` a validating TLS context (check_hostname +
    CERT_REQUIRED), so the badge-delivery client no longer exposes its SMTP AUTH
    credentials to an on-path attacker (#203).
  - fix(cli): `openbadges-verifier` exits non-zero in human mode when an OB1/OB2
    badge is invalid (was exit 0), matching OB3 and `--json` (#189).
  - fix(cli): `openbadges-signer` surfaces a partial-batch exit code in human
    mode (#191); `-H/--hosted` is rejected outside `-V 2` (#206); a badge section
    missing `private_key`/`public_key` yields a clean error (#210).
  - fix(cli): `openbadges-init -h/--help` prints usage and exits 0 instead of
    creating a directory named `--help` (#207).
  - fix(issue): a baking failure surfaces as `IssuanceError` on the vc-jwt/OB2
    paths (#190), and a missing `[issuer]`/config key does too (#208).
  - fix(publish): isolate a per-badge key failure in status-list regeneration so
    one bad badge no longer aborts the whole publish (#192).
  - fix(ob3): a non-string Data Integrity `proof.created`/`expires` raises
    `OB3VerificationError` instead of a raw `AttributeError` (#193).
  - fix(ob2): accept an embedded `CryptographicKey` object in a Profile's
    `publicKey` (#194).
  - fix(confparser): reject a `status_size_bits` that is not a positive multiple
    of 8 as a clean config error (#204).
  - fix(keygenerator): switch on debug logging at the start of the run, so `-d`
    also covers config reading and validation — not just the tail of key
    generation, where `enable_debug_logging` used to sit.
  - docs: announce the 4.0.0 deprecation window ahead of the release — a new
    "Upgrading to 4.0" wiki page (dropped Python 3.10, the unified 0/1/2
    exit-code contract, the removed pycryptodome/python-ecdsa compat) linked
    from the sidebar, plus a README heads-up.
  - feat(eudi): `issue_badge_sd_jwt` can embed an `x5c` certificate chain, so a
    badge SD-JWT VC is issued anchored on X.509 and verified in one call with
    `verify_badge_sd_jwt(..., x5c_trust_anchors=[…])` — the issue-side
    counterpart of the verify-side x5c trust (#178), for eIDAS / EU Trusted
    List anchoring. Raises the [eudi] openvc-core floor to >=1.18.

* v3.13.0 - 2026-07-09

  - feat(eudi): **opt-in X.509 / EU Trusted List trust for received SD-JWT
    badges** (#178). `verify_badge_sd_jwt(..., x5c_trust_anchors=…)` verifies a
    third-party badge whose issuer JWT carries an `x5c` chain by routing through
    openvc-core's `verify_credential` pipeline, which path-validates the chain
    to the caller's X.509 roots and **binds it to `iss`** before using the leaf
    key — the eIDAS trust root the JWK-pinned suite path cannot reach. Build the
    anchors from an EU Trusted List with
    `openvc.trustlist.walk_lotl(...).certificates` (LOTL → national TLs,
    fail-closed, caller-pinned signers, XXE-hardened) or pass your own roots.
    JWK-pinning stays the default — exactly one of `pubkey_pem` /
    `x5c_trust_anchors` is required — and the return type is unchanged
    (openvc-core's `VerifiedSdJwt`). Status is not checked on this path, as on
    the pinned one. (Closes #178)

* v3.12.0 - 2026-07-09

  - feat(eudi): publish SD-JWT VC **Type Metadata** for the badge `vct`, so a
    library-issued SD-JWT badge is self-describing to EUDI wallets (#176). New
    `openbadgeslib.ob3.eudi` helpers build the Type Metadata document for the
    OB 3.0 badge claim set (`badge_type_metadata` — `achievement`/`name`/
    `validFrom` mandatory, the recipient `credentialSubject` selectively
    disclosable), serialize the exact bytes to serve
    (`type_metadata_document_bytes`) and compute its `vct#integrity` W3C SRI pin
    (`type_metadata_integrity`); `issue_badge_sd_jwt(vct_integrity=…)` embeds
    that pin (always disclosed, alongside `vct`). Opt-in on the publish side:
    set `[issuer] sd_jwt_vct` to an issuer-hosted vct URL and
    `openbadges-publish -V 3` writes the document into the webroot at that path
    (byte-exact, covered by `--check-live`) — the imsglobal purl stays the
    default, so nothing changes for existing issuers. Validated end to end
    against openvc-core ≥1.2's Type Metadata verifier (resolve → integrity →
    mandatory-claim checks): openvc-core does the verifier side, this is the
    issuer/publisher side. (Closes #176)

* v3.11.0 - 2026-07-09

  - feat(ldp): verify OpenBadges 3.0 credentials secured with the
    **ecdsa-sd-2023** (selective disclosure) Data Integrity cryptosuite — the
    verify-only slice of #171. A derived proof is verified by delegating the
    large, security-sensitive selective-disclosure crypto (CBOR base/derived
    proofs, HMAC label maps, per-statement P-256 signatures) to openvc-core's
    audited `EcdsaSdProofSuite`; openbadgeslib keeps the OB3 model, the trust
    binding (proof `verificationMethod` ↔ credential issuer) and the lifecycle
    checks around it, and hands the delegate its pinned OB3 `@context`
    allowlist so canonicalization stays fail-closed and never touches the
    network. It plugs into the `_CRYPTOSUITES` registry beside eddsa-rdfc-2022,
    so `OB3LdpVerifier` accepts both transparently. Verify-only for Displayer
    parity (1EdTech OB3 certification requires verifying both suites); issuing
    ecdsa-sd-2023 stays out of scope. Needs the new `[ldp-sd]` extra
    (`openvc-core[data-integrity]`, self-contained — pulls its own pyld); a
    missing extra fails closed with an actionable install hint. (Closes #171)

* v3.10.0 - 2026-07-08

  - feat(publish): `openbadges-publish -V 3 --check-live` completes #164. After
    (re)publishing, it downloads each written artifact — `did.json`, the signed
    status lists, `verify.pem` — from `publish_url` and byte-compares it against
    the local copy, turning the "re-upload so the change takes effect" reminder
    into a verifiable guarantee. Exit 2 (`--json`, listed in `live_check.stale`)
    or 1 (human) if any artifact is stale or missing on the server. The rest of
    #164 (status-list `validUntil` + opt-in list-proof verification + issuer
    binding) shipped in v3.8.0. (Closes #164)

  - docs(examples): #168 library integration tutorial + runnable examples. New
    `examples/` scripts — issue+verify an OB 3.0 JWT-VC, issue with a Data
    Integrity (LDP) proof, and issue from a config section via the high-level API
    (single + batch) — each self-contained and executed in CI (`test_examples.py`,
    anti-drift, same philosophy as `test_docs.py`). Two new wiki pages: the
    "Library Integration Tutorial" narrating them end to end (issue → sign →
    publish → revoke → verify) and a "Certification Cookbook" on passing the
    official 1EdTech OB 3.0 conformance tests without the paid membership.
    (Closes #168)

  - test(coverage): #169 targeted-coverage round. New dedicated suites for the
    shared token carrier (`test_baking.py` — embed/extract round-trips per
    format, compression and malformed-iTXt edges), mail (`test_mail.py` — SMTP
    mocked) and the key helpers (`test_keys.py`), plus error-branch coverage for
    the OB2 signer/verifier, the OB3 Data Integrity (LDP) path and the verifier
    CLI (`openbadges_verifier.py` 74% → 95%). Total coverage crosses 93% and the
    enforced `fail_under` floor is raised from 91 to 93. An opt-in OB2
    **SignedBadge** conformance case joins the existing hosted one (the
    crypto-sensitive path, Docker-gated). (Closes #169)

  - docs(ob1): OpenBadges 1.0 is reclassified from "deprecated, removal in
    4.0.0" to a **supported legacy surface with no removal planned**. Removing a
    working, dependency-free legacy path (OB1 has carried no unique dependencies
    since #167) was judged excessive; OB1 stays fully functional and maintained,
    simply superseded by OB 2.0/3.0 for new work. The library
    `DeprecationWarning`s and the `-V 1` CLI notices are reworded to steer new
    work to OB2/OB3 without promising removal, and the "OpenBadges 1.0
    lifecycle" wiki page records the policy — including why a `[legacy]`/`[ob1]`
    install extra stays rejected: a Python extra gates *dependencies*, not code,
    and OB1 has none to gate (it would install the same bytes). Reverses the
    removal side of #159/#170.

  - feat(cli): batch issuance — `openbadges-signer` now issues to many recipients
    in one run. Repeat `-r EMAIL`, or pass `--recipients-file FILE` (one email per
    line or comma-separated; blank lines and `#`-comments ignored); a single `-r`
    keeps the historical single-badge behaviour byte-for-byte. For a revocable OB3
    badge every status-list index is allocated in a SINGLE registry transaction
    (load once, allocate N, save once) rather than N load/save cycles — the piece
    that makes thousands-scale issuance practical. An already-existing output file
    is skipped (batch) or aborts (single) unless the new `--force`/`--overwrite`
    overwrites it, and one bad recipient does not abort the rest. `--json` emits a
    per-recipient summary `{signed, skipped, failed}` under the shared exit
    contract (0 all signed, 2 some skipped/failed, 1 a batch-level error). The
    library entry point is `openbadgeslib.issue.issue_batch_from_conf`, returning
    one `BatchResult` per recipient. (Closes #165)

  - feat(api): extract the issuance orchestration from the CLI into a reusable
    library API — the single biggest adoption gap. `openbadgeslib.issue` exposes
    `issue_from_conf(conf, badge, recipient, ob_version, ...)` (also re-exported
    as `openbadgeslib.issue_from_conf`), returning a `SignResult` dataclass with
    the signed badge bytes and metadata and doing no user-facing I/O — no
    prints, no sys.exit, no file write; a config or policy problem raises
    `IssuanceError`. The ~230 lines of business logic that lived in
    openbadges-signer (credential construction from config, salt generation, the
    OB2 hosted-vs-signed decision, the OB3 status-registry→sign transactional
    order and the Data Integrity verificationMethod policy — did:web trusted vs
    a derived self-asserted did:key) now live there and are unit-tested
    directly, not only reachable by driving the CLI. The signer entrypoints are
    reduced to flag parsing, I/O and display over that API; behaviour — including
    the historical per-version exit conventions — is unchanged. Prerequisite for
    batch signing (#165) and any unified CLI. OpenBadges 1.0 issuance stays
    CLI-only (it is deprecated). (Closes #160)

  - feat(ob3): complete the broadened OB 3.0 credential model started in
    v3.9.0 — closing the remaining data-model gap versus the spec. New `Result`
    (a measured outcome: value, status, achievedLevel and the linked
    resultDescription), `ResultDescription` (an achievement's possible results:
    resultType, allowedValue, valueMin/valueMax, requiredValue/requiredLevel)
    and `IdentityObject` (a hashed or plaintext `credentialSubject.identifier`,
    the alternative to `credentialSubject.id`) dataclasses, plus `creditsEarned`
    on the subject and a `resultDescription` list on `Achievement`. All follow
    the house style (dataclasses + mypy, no Pydantic), parse from a verified
    credential, round-trip through `to_vc`, and satisfy the offline conformance
    schema — a fully-populated credential (identifier-only subject, result +
    linked resultDescription, alignment, credits) validates against 1EdTech's
    AchievementCredential schema. `openbadges-verifier` now surfaces the
    broadened fields — achievementType, creditsAvailable/creditsEarned and the
    alignment/result/identifier counts — in both `--json` and `--show`. Two
    round-trip fixes fall out: a subject conveying identity solely through
    `identifier` now re-serialises with it (the identifier was silently dropped
    on parse before), and a lone `alignment` object not wrapped in an array is
    no longer discarded. (Closes #162)

  - fix(cli): `openbadges-publish -V 1/-V 2` now exits with a clear message when
    the `[issuer]` section is missing `publish_url` or `revocationList`, instead
    of raising a raw `KeyError` traceback partway through — after the output
    directory had already been created. The OB3 path already validated this; the
    hosted OB1/OB2 paths now share the same guard. Last item of the quick-fix
    round. (Closes #156)


* v3.9.0 - 2026-07-08

  - feat(ob3): broaden the credential data model toward the OB 3.0 spec — the
    gap versus the spec was the model, not the crypto. New `Evidence` (the full
    `evidence` array: narrative, name, description, genre, audience — beyond the
    single `evidence_url`) and `Alignment` (competency-framework mappings, what
    LMSes read to map a badge to a competency) dataclasses, plus
    `achievementType`, `creditsAvailable` and an `alignments` list on
    `Achievement`. All are validated in the house style (dataclasses + mypy, no
    Pydantic), parse from a verified credential, round-trip through `to_vc`, and
    still satisfy the offline conformance schema. (Refs #162 —
    Result/ResultDescription, IdentityObject and creditsEarned follow.)

  - feat(ob3): support OpenBadges 3.0 `endorsementJwt` (spec errata v1.6, added
    to context 3.0.3). Third-party endorsement JWTs were passing through as
    opaque, unmapped fields; the credential model now exposes them at the three
    levels the spec allows — `credential.endorsement_jwts`,
    `issuer.endorsement_jwts`, `achievement.endorsement_jwts`, gathered by
    `credential.all_endorsement_jwts()` — and round-trips them through `to_vc`.
    A new `ob3.verify_endorsement_jwt(token, download=None,
    endorser_pubkey_pem=None)` verifies an EndorsementCredential JWT under the
    *endorser's* key (resolved from its own issuer did:web/did:key, or an
    explicit key when the endorser is not a DID), checking the
    EndorsementCredential type and its validFrom/validUntil window.
    `openbadges-verifier --json` now reports the `endorsements` count. (#161)


* v3.8.0 - 2026-07-08

  - security(ob3): status lists gain replay protection and opt-in proof
    verification, closing two documented trust gaps. (1) A per-badge
    `status_validity_days` makes published lists carry a `validUntil`, and the
    reader now enforces the list's validFrom/validUntil window — a lapsed
    (replayed, or no-longer-republished) `revocation.jwt` is rejected instead
    of silently resurrecting a revoked badge. (2)
    `check_credential_status(..., verify_list=True)` (and
    `OB3Verifier.check_status(..., verify_list=True)`) additionally verifies
    the status list credential's own JWT-VC proof and binds its issuer to the
    badge's, so a compromised status host cannot serve a validly-shaped list
    that silently un-revokes. The list is signed with the badge key, so that
    same key (reused automatically) verifies it; a did:web/did:key issuer is
    resolved otherwise. Both are opt-in — the default stays a single
    offline-friendly fetch, no behaviour break. (#164)


* v3.7.0 - 2026-07-08

  - security(deps): `keys.py` (and the OB1 key-loading path) are ported to
    `cryptography`, and both `pycryptodome` and `python-ecdsa` are dropped as
    dependencies. They only generated and parsed key material here — signing
    always went through PyJWT+`cryptography` — all of which `cryptography`
    (already a hard dependency via `PyJWT[crypto]`) covers. This removes
    python-ecdsa's permanent pip-audit flag (CVE-2024-23342 "Minerva", wontfix
    upstream), moves ECC key generation onto constant-time code, and shrinks
    the install to a single crypto backend. PEM output is byte-compatible
    (PKCS#1 `RSA PRIVATE KEY`, SEC1 `EC PRIVATE KEY`, SubjectPublicKeyInfo
    public keys), so existing key files round-trip. `key_to_pem` still accepts
    a live pycryptodome/python-ecdsa key object via a soft import, for a caller
    predating the port. (#167)

  - feat(cli): `--json` machine-readable output and a documented exit-code
    contract for `openbadges-signer`, `openbadges-publish` and
    `openbadges-keygenerator` (the verifier already had one), so issuance can
    be automated without scraping human text. Each prints exactly one JSON
    object to stdout — the signer `{ob_version, badge_file, jti, status_index,
    proof_format}`, the keygenerator `{key_type, private_key, public_key}`,
    publish `{did, files_written, status_operation, skipped}`, and
    `--list`/`--status` the queried records — under a shared exit contract:
    `0` success, `2` partial (some badges skipped), `1` any error (reported as
    `{"error": "..."}`). Human output and its historical (inconsistent) exit
    codes are untouched — unifying those is reserved for 4.0.0. `publish
    --json` is OpenBadges 3.0 only. (#166)

  - feat(ob3): JWT-VC verification now honours the token's `kid` header for a
    multi-key did:web issuer. `OB3Verifier.for_issuer_did` fetches the DID
    document once and selects the exact `verificationMethod` the kid names (an
    absolute `did:web:…#badge_N` URL, or a bare `#badge_N` resolved against the
    issuer DID), so issuers that rotate keys or publish one key per badge now
    verify without pinning `-k`/`-l` — closing the gap where only the first
    listed key worked. Fails closed when the kid names a method of a *different*
    DID (the header is attacker-controlled) or one absent from the document; a
    token with no kid still falls back to the first method. (#163)

  - feat(ob1): OpenBadges 1.0 is now deprecated (removal scheduled for the
    grouped 4.0.0 release, #170). Reaching the legacy import surface — the
    `openbadgeslib.ob1` package, the top-level `openbadgeslib.badge` /
    `openbadgeslib.signer` / `openbadgeslib.verifier` shims, and the
    unprefixed `openbadgeslib.Signer` / `Verifier` / `Badge` / … re-exports —
    now emits a `DeprecationWarning`, and the `-V 1` CLI paths (signer,
    verifier, publish) print a `[!]` notice (suppressed under the verifier's
    `--json`, so machine output stays clean). OB1 stays fully functional; a
    bare `import openbadgeslib` and the OB2/OB3 APIs stay silent. OpenBadges
    2.0 is **not** affected — it remains fully supported. The lifecycle policy
    and the rejected alternatives (a `[legacy]` extra, a separate PyPI
    package) are recorded on the "Python API OB1" wiki page. (#159)

  - feat(publish): `openbadges-publish -V 3` gains two read-only registry
    query commands, closing the credential lifecycle from the CLI
    (issue → revoke → audit). `--list [-b badge]` tabulates every issued
    credential — jti, recipient, issue date and state (active / REVOKED /
    SUSPENDED); `--status <jti|email>` prints the full record of the matching
    credential(s), including the revocation or suspension date and reason.
    Both read only the private status registries, so neither needs an output
    directory (`-o`). (#157)

  - refactor(cli): `openbadges-keygenerator` no longer accepts the decorative
    `-V/--ob-version` flag. Key material never depended on the OpenBadges
    version (the algorithm comes from the badge's `key_type`), so the flag only
    changed a log line. Internal tidy-ups alongside it: a dead `if args.badge`
    guard in the signer (`-b` is required), the redundant `tests/runtests.sh`
    wrapper, and an inaccurate conftest comment. (#156)


* v3.6.0 - 2026-07-08

  - feat(ob3): the verifier now exposes the raw, already-validated VC document
    on the returned credential (`credential.raw`) — on both the JWT-VC and the
    Data Integrity path — so an integrator can read spec fields the dataclass
    does not map (alignment, results, multiple evidence, endorsements, …)
    without re-parsing the token. It is None for an in-memory credential built
    to issue, and excluded from equality/repr so it never affects comparisons.

  - feat(ob3): issued OB 3.0 credentials now carry a `credentialSchema` entry
    (`1EdTechJsonSchemaValidator2019`) pointing at 1EdTech's published
    AchievementCredential JSON Schema, so validators and wallets can
    self-check them. The offline conformance gate proves our issued
    credentials satisfy that exact schema.

  - refactor(typing): the library now type-checks clean under `mypy --strict`
    (73 bare `dict` annotations parameterised as `dict[str, Any]`, 20 Any
    returns narrowed with `typing.cast`), and `strict = true` is enabled in
    the mypy config so CI enforces it. This makes the README's long-standing
    `mypy --strict` claim true and improves the shipped `py.typed` annotations
    for downstream type-checkers. Annotations only — no runtime change.


* v3.5.1 - 2026-07-07

  - fix(ob3): a naive datetime passed as the issuance/expiration date is now
    assumed UTC (matching status_registry._iso_z), not local time — so
    validFrom/validUntil no longer silently shift by the host's offset and
    stay consistent with the nbf/exp JWT claims.

  - fix(ob3): the JWT verifier now cross-checks the `jti` claim against the
    credential `id` (OB3 §8.2 binds them). A validly signed token whose jti
    disagrees with — or omits — the credential id is rejected, alongside the
    existing iss/nbf/sub checks.

  - fix(ob3): `_b58btc_decode` bounds its input to 128 characters before the
    O(n^2) base58 decode, so a hostile did:key / publicKeyMultibase in a
    fetched did:web or Data Integrity document (up to 5 MiB / 256 KiB) can no
    longer burn CPU. No real multikey exceeds ~70 characters.

  - fix(ob2): an `evidence` array (which OB 2.0 permits) is preserved when
    parsing an Assertion instead of being silently dropped — `_iri_or_none`
    returned None for a list. A single value still collapses to its IRI.

  - fix(cli): `openbadges-signer -M/--mail-badge` now prints a clear notice
    on the default OB3 path (it was a silent no-op there); the stale
    `openbadges-keygenerator -t ED25519` hint on the LDP path is corrected to
    the real `key_type = ED25519` config key; and the OB1 mail path reads its
    `[smtp]` config inside the try, so a missing `[smtp]` section is reported
    cleanly after signing instead of raising a traceback.

  - fix(publish): `openbadges-publish -V 3` no longer aborts mid-loop when a
    badge has an unreadable key or corrupt registry — it skips that badge
    with a notice (like the did.json path already did), regenerates the
    others, and exits non-zero with a summary. An urgent revocation is no
    longer masked by an unrelated half-configured badge.

  - refactor(keygen): `openbadges-keygenerator` uses `enable_debug_logging`
    instead of the legacy `Logger` class (now removed from logs.py), so key
    generation no longer requires the `[paths]`/`[logs]` config sections just
    to emit console lines (they raised a raw KeyError/FileNotFoundError), and
    a missing `[issuer] name` degrades gracefully.


* v3.5.0 - 2026-07-07

  - fix(status): serialise status-registry writes with an exclusive
    inter-process lock. Concurrent `openbadges-signer` runs (or a signer
    overlapping `openbadges-publish --revoke`) each did load→mutate→save with
    no lock, so the second `save()` clobbered the first's new entry — and a
    delivered credential missing from the registry can never be revoked. Both
    mutation paths now run inside the new `StatusRegistry.locked(path)` context
    manager, which holds a POSIX `fcntl` lock on a sibling `<registry>.lock`
    file across the whole load→mutate→save (a dedicated lock file, because
    `save()` renames the JSON and a lock on its inode would be orphaned). The
    revoke path searches unlocked and reloads the winning registry under the
    lock before mutating. Where `fcntl` is unavailable (non-POSIX) it degrades
    to the previous unlocked behaviour, so single-operator use is unchanged.

  - feat(eudi): the SD-JWT VC track now also signs with NIST **P-384**
    (ES384) keys, not only Ed25519 and P-256. `issue_badge_sd_jwt` reads the
    ECDSA curve from the key and picks openvc-core's matching backend
    (`P256SigningKey`/`P384SigningKey`), stamping an `alg: ES384` issuer JWT
    for a P-384 PEM; verification was already curve-agnostic. Other curves
    (e.g. P-521) and RSA are still rejected with an actionable message. New
    `openbadgeslib.keys.ec_curve_from_pem` helper. Note: HAIP profiles the
    P-256 family, so P-384 badges may be refused by HAIP-strict EUDI wallets
    — P-256 remains the recommended default.


* v3.4.2 - 2026-07-07

  - build(eudi): the `[eudi]` extra now tracks `openvc-core>=1.8,<2` (was
    `>=0.8,<0.9`). openvc-core reached 1.0 with a published SemVer/deprecation
    contract and an add-only return-object guarantee, so the tight pre-1.0 pin
    is no longer needed — the pin now floors at the tested version and caps at
    the next major. The SD-JWT VC API openbadgeslib consumes
    (`SdJwtVcProofSuite.issue/.verify`, `Ed25519SigningKey`, `P256SigningKey`)
    is unchanged across the jump and the full test suite passes against
    openvc-core 1.8.0. Note the 1.0 breaking changes (schema resolver returns
    bytes, proof-error taxonomy move) touch surfaces openbadgeslib never
    imports.


* v3.4.1 - 2026-07-06

  - build: advertise Python 3.14 support — add it to the CI test matrix (lint
    + mypy + the full suite now run on 3.10–3.14) and the trove classifier, so
    3.14 shows as supported on PyPI. No code change; the suite already runs
    clean on CPython 3.14.


* v3.4.0 - 2026-07-06

  - feat(ob3): issue and verify Open Badges 3.0 as EUDI **SD-JWT VC** (the
    EU Digital Identity Wallet / ARF format), delegating the crypto to the
    generic openvc-core library — new `openbadgeslib.ob3.eudi` module
    (`issue_badge_sd_jwt`, `verify_badge_sd_jwt`, `badge_to_sd_jwt_claims`).
    Additive: the native OB 3.0 VC-JWT and Data Integrity issuance stay
    untouched and conformant — this is a separate credential format for
    wallet flows, not a swap. The achievement is always disclosed while the
    recipient identity is selectively disclosable, so a holder can prove the
    badge yet withhold who they are; key-binding presentations (holder `cnf`,
    audience + nonce) are supported. Ed25519 (EdDSA) and NIST P-256 (ES256)
    keys only — the SD-JWT algorithm set; RSA is rejected with a clear
    message. Needs the new optional `[eudi]` extra; the base install is
    unchanged.

  - build(eudi): the `[eudi]` extra now tracks `openvc-core>=0.8,<0.9` (was
    `>=0.3.1,<0.4`). The SD-JWT VC API openbadgeslib consumes is unchanged
    across the bump and the full test suite passes against openvc-core 0.8.
    That extra transitively requires `cryptography>=45`, while the base
    install floor stays `>=42` for non-EUDI users.


* v3.3.0 - 2026-07-04

  - feat(ob3): issuance of OB 3.0 credentials secured with a W3C Data
    Integrity proof (eddsa-rdfc-2022) — new `OB3LdpSigner` and low-level
    `add_data_integrity_proof`, the signing counterparts of the verifiers
    shipped in 3.2.0, sharing their canonicalization core and reproducing
    the official W3C vc-di-eddsa test vector byte for byte. Ed25519 keys
    only; requires the `[ldp]` extra, which now covers both directions.

  - feat(cli): `openbadges-signer -P/--proof-format {vc-jwt,ldp}` (OB3
    only) plus an optional per-badge `proof_format` config key (flag wins;
    default vc-jwt). With `[issuer] did` configured the proof names the
    `did:web:...#badge_N` verification method `openbadges-publish -V 3`
    publishes (trusted via `--resolve-did`); otherwise it falls back to a
    self-asserted did:key derived from the signing key and tells the
    operator so. LDP-signed badges are tagged `PROOF ldp` in the signer
    audit log. Status lists stay VC-JWT regardless of the badge's proof
    format.

  - feat(ob3): multikey/did:key encoders in `ob3.did` —
    `multikey_from_pem` (publicKeyMultibase) and `did_key_from_pem`,
    the exact inverses of the resolvers, for Ed25519 and NIST P-256.

  - fix(cli): `Badge.create_from_conf` rejected `key_type = ED25519`
    outright, so the CLI could never sign with the keys
    `openbadges-keygenerator -t ED25519` produces; Ed25519 key material
    now loads, and the legacy OB 1.0 path (`-V 1`) refuses non-RSA/ECC
    keys with a clean message instead of failing mid-JWS.


* v3.2.0 - 2026-07-03

  - feat(ob3): verification of OB 3.0 credentials secured with a W3C Data
    Integrity proof (the Linked Data Proof format), cryptosuite
    eddsa-rdfc-2022 — new `OB3LdpVerifier` and low-level
    `verify_data_integrity_proof`, validated against the official W3C
    vc-di-eddsa test vectors. Verify-only: issuing stays JWT-VC. Requires
    the new optional extra: `pip install "openbadgeslib[ldp]"` (pyld);
    everything else works without it.

  - feat(ob3): JSON-LD @context documents are never fetched from the
    network — the exact context files ship pinned inside the wheel behind
    an exact-match allowlist (fail-closed on unknown contexts), and Data
    Integrity documents are capped at 256 KiB before canonicalization.

  - feat(ob3): `resolve_verification_method` resolves the exact
    verificationMethod URL a proof names (did:key fragment validation;
    did:web entry selected by id, fail-closed on no match).

  - feat(baking): OB 3.0 §5.3 text-content carrier for Data Integrity
    credentials — `bake_svg(..., as_text=True)` and an opt-in
    `extract_svg(..., text_fallback=True)`; the OB3 extraction returns
    either a compact JWT or the credential JSON. PNG needed no change.

  - feat(cli): `openbadges-verifier -V 3` autodetects the baked payload
    (JWT-VC vs Data Integrity) with the same trust flags, reports
    `proof_format` in `--json`, and hints at the missing `[ldp]` extra.

  - feat(api): `OpenBadgeCredential.from_vc_document` reconstructs a
    credential from its JSON-LD document shape (same validation as the
    JWT payload path).


* v3.1.1 - 2026-07-03

  - docs(packaging): discoverability metadata — a descriptive PyPI summary,
    an expanded keyword/classifier set (verifiable-credentials, vc-jwt,
    did-web, bitstring-status-list, revocation, education, cryptography), a
    "Why openbadgeslib" section and an honest OB 3.0 Python comparison table in
    the README. No functional change.


* v3.1.0 - 2026-07-02

  - feat(ob3): issuer-side credential status lifecycle. Badges that opt in
    with `status_lists = revocation, suspension` in their config section get
    W3C Bitstring Status List entries attached to every newly signed
    credential (`openbadges-signer -V 3`), with the index recorded in a
    private per-badge registry (`${base}/status/badge_N.json`, random
    allocation, atomic writes).

  - feat(publish): `openbadges-publish -V 3` is no longer a no-op. It
    generates the issuer's did:web document (`did.json`) and, per opted-in
    badge, the signed status list credentials (`revocation.jwt`,
    `suspension.jwt`, signed with the badge key) plus `verify.pem`. New
    flags `--revoke ID`, `--suspend ID`, `--unsuspend ID` (ID = jti or
    recipient email; `-b` scopes, `--reason` annotates) update the registry
    and regenerate the lists; revocation is permanent. The `-V 3` output
    directory may already exist and its files are replaced atomically.

  - feat(config): new opt-in keys — `[paths] base_status`, `[issuer] did`
    (`auto` derives a did:web issuer id from `publish_url`), and per-badge
    `status_lists` / `status_size_bits` / `status_base`. Configs without
    them behave exactly as before. `openbadges-init` now also creates
    `status/`.

  - feat(api): new public API — `openbadgeslib.ob3.status_list`
    (`encode_bitstring`, `build_status_list_credential`,
    `sign_status_list_credential`, `status_entry`),
    `openbadgeslib.ob3.StatusRegistry`, `openbadgeslib.ob3.did`
    (`did_web_from_url`, `build_did_document`),
    `openbadgeslib.keys.public_jwk_from_pem` and
    `OB3Signer.sign_payload`. The OB3 signer path now also appends to the
    signer log, including the credential jti and status index.


* v3.0.1 - 2026-07-02

  - fix(cli): the OpenBadges 1.0 signer (`-V 1`) now writes the signed badge
    before appending to the audit log, and guards the log write, so a missing
    or unwritable `base_log` reports a clean error instead of crashing with a
    raw OSError and losing the already-signed badge (matches the OB2 path).


* v3.0.0 - 2026-07-01

  - BREAKING: the pre-2.0 wire format previously shipped as `-V 2` is relabelled
    OpenBadges 1.0 (`-V 1`) and frozen in the new `openbadgeslib.ob1` package (no
    @context/type, a `uid`, a `verify` object, string `hashed`, Unix-timestamp
    dates). `from openbadgeslib.ob2 import Signer/Verifier/Badge` no longer
    resolves — import from `openbadgeslib.ob1` (the top-level
    `openbadgeslib.signer` / `verifier` / `badge` shims still work).

  - BREAKING: the default `-V` is now `3` (was `2`) for openbadges-signer,
    -verifier, -publish and -keygenerator. Pass `-V 2` / `-V 1` explicitly for
    the older generations.

  - feat(ob2): new strict, spec-conformant Open Badges 2.0 implementation
    (`openbadgeslib.ob2`). Assertions are valid JSON-LD Badge Objects with
    `@context`, `type`, an IRI `id` (`urn:uuid:` for signed, the hosting URL for
    hosted), a boolean `hashed`, ISO 8601 dates, and a `verification` object.
    New `OB2Signer`, `OB2Verifier`, and dataclasses `Assertion`, `BadgeClass`,
    `Profile`, `CryptographicKey`, `RevocationList`.

  - feat(ob2): real HostedBadge verification — the assertion is fetched from its
    own `id` over HTTPS and scope-checked against the issuer origin (default
    same-origin, or the issuer's `startsWith` / `allowedOrigins`); the baked JWS
    is non-gating defence-in-depth. Select it with `openbadges-signer -V 2 -H`.

  - feat(ob2): SignedBadge verification resolves `verification.creator` to a
    published `CryptographicKey` and checks its `owner` / `publicKey` back-link
    to the issuer Profile.

  - feat(publish): `openbadges-publish -V 2` emits conformant hosted metadata —
    an issuer Profile with a `publicKey` array, a `BadgeClass` and a
    `CryptographicKey` (`key.json`) per badge, and a `RevocationList`.

  - feat(config): new optional badge keys `crypto_key` and
    `hosted_assertions_base` for the OB 2.0 signed / hosted flows.

  - OpenBadges 3.0 is unchanged.


* v2.0.0 - 2026-07-01

  - BREAKING (OB3): OpenBadges 3.0 credentials are now secured with the native
    OB 3.0 VC-JWT (spec §8.2): the JWT payload IS the credential (its members at
    the top level, no 'vc' claim wrapper), validFrom maps to 'nbf' (there is no
    'iat'), and the JOSE header carries the issuer's public key as a 'jwk'.
    Tokens issued by 1.x (the VCDM-1.1-style 'vc'-wrapper) are NOT compatible.

  - BREAKING (OB3): baked images use the OB 3.0 document-format identifiers —
    the PNG iTXt keyword 'openbadgecredential' and the SVG element
    <openbadges:credential> (namespace https://purl.imsglobal.org/ob/v3p0). OB3
    images baked by 1.x (OB2 identifiers) are not read by this verifier.

  - OB3 verifier now accepts credentials that are valid per the spec schema but
    were previously rejected: the AchievementCredential type alias, an issuer
    given as a string IRI (not only a Profile object), and a credentialSubject
    without an 'id' (identity conveyed via 'identifier').

  - OB3 verifier now enforces required structure it previously ignored: the
    @context (VC 2.0 + OB v3p0 pair) is validated, and the registered claims
    'iss' and 'nbf' are required (with 'sub' required when the subject has an id).

  - OB3 credentialStatus now honors statusPurpose: 'suspension' is reported
    distinctly from 'revocation', a non-revocation/suspension purpose (e.g.
    'message') no longer fails verification, and the entry's purpose is
    cross-checked against the fetched status list's declared purpose.

  - OpenBadges 2.0 is unchanged; only the OB3 path is affected.


* v1.3.0 - 2026-07-01

  - SECURITY: download_file() now blocks server-side request forgery. The URLs
    it fetches are attacker-influenced (an OB2 badge/issuer/revocationList URL,
    an OB3 did:web host, an OB3 credentialStatus list), so it now resolves the
    destination host and refuses any private, loopback, link-local, reserved,
    multicast, unspecified or carrier-grade-NAT (100.64/10) address; the check
    is re-applied to redirect targets. Previously a crafted badge could steer a
    verifier into GETting cloud-metadata endpoints or internal hosts. An
    allow_private=True opt-out is available for private deployments.

  - SECURITY: OB3Verifier.for_issuer_did() now binds the credential to the
    anchored DID — verify() requires the credential's issuer id to equal the
    DID whose key was resolved. did:web is not self-certifying, so without this
    a credential signed by the resolved key could claim a different (trusted)
    issuer and be accepted. Verifiers built directly from a public key are
    unchanged (the caller vouches for the key).

  - SECURITY: openbadges-verifier --json exit status now reflects issuer trust,
    not just signature validity: 0 = valid AND issuer-trusted, 2 = valid
    signature but untrusted issuer (an OB2 badge-embedded key or a self-asserted
    did:key), 1 = failure. In 1.2.0 a valid-but-untrusted badge exited 0, so CI
    gating on the exit code accepted signatures that do not prove issuer
    identity. NOTE: this changes the exit code for the valid-but-untrusted case
    from 0 to 2; the JSON body (valid/trusted/reason) is unchanged.

  - SECURITY: OB3 --resolve-did on a self-asserted did:key is now reported
    trusted:false (with a warning), mirroring OB2's badge-embedded-key case;
    only an operator-supplied key or a DNS/TLS-anchored did:web is trusted.

  - fix: an OB3 Bitstring Status List with statusSize > 1 (multi-bit entries)
    now fails closed instead of reading the wrong bit, which could have
    reported a revoked credential as valid.

  - fix: confparser now resolves a relative [paths] base against the directory
    containing the config file (not the process CWD). Previously only a bare
    '.' was handled and its value replaced wholesale, silently dropping the
    suffix of './data' or '../shared' and misplacing key/log/image trees. A '$'
    in the config directory path is handled correctly.

  - docs: Ed25519 (EdDSA) support is now documented across README and the wiki
    (key types, algorithms, keygenerator key_type, config example); the --json
    exit-status scheme is documented; and stale/inaccurate crypto-library and
    dependency notes were corrected.


* v1.2.0 - 2026-07-01

  - feat: openbadges-verifier gained a --json flag for machine-readable output.
    It prints a single JSON object ({valid, ob_version, recipient, reason, and
    version-specific fields such as OB2 trusted/status or OB3
    issuer/achievement/…}) instead of the human [+]/[-]/[~] lines, and exits 0
    when valid / non-zero otherwise — usable in CI and services without
    scraping stdout. The default (human) output and its exit codes are
    unchanged.

  - feat: OB3 issuer DIDs can now be resolved to a verification key. New
    ob3.resolve_did() and OB3Verifier.for_issuer_did() support did:key (Ed25519
    and P-256, self-certifying, offline) and did:web (fetches the DID document
    over HTTPS and reads its first verificationMethod's publicKeyJwk or
    publicKeyMultibase). openbadges-verifier gained a --resolve-did flag: when
    no trusted key is supplied for an OB3 badge, the issuer DID is read from the
    token and resolved, and the signature is checked against the resolved key.
    did:key needs no external trust; did:web trusts the host's DNS and TLS
    (documented in the Security Model).

  - SECURITY: OB3 credential revocation is now checkable. OB3Verifier.verify()
    gained an opt-in check_status=True (and openbadges-verifier a --check-status
    flag) that resolves each credentialStatus entry — Bitstring Status List v1.0
    and the legacy StatusList2021 — fetches the status list over HTTPS, and
    rejects a revoked/suspended credential. Previously OB3 had no revocation
    control at all (a revoked credential verified as VALID). The check is
    fail-closed when enabled (an unresolvable/malformed status list is a
    failure, not a pass) and the GZIP inflate of the bitstring is bounded. It
    verifies the published status bit only, not the status-list credential's own
    signature (documented).

  - feat: Ed25519 (EdDSA) keys are now supported end to end — key generation
    (set key_type = ED25519 in the badge profile), plus OB2 JWS and OB3 JWT-VC
    signing and verification. detect_key_type classifies an Ed25519 PEM
    explicitly (the ecdsa library would otherwise misread it as an ECC/NIST
    key), and the algorithm-pinning allowlists bind EdDSA to Ed25519 keys, so
    cross-type tokens are still rejected. cryptography is now an explicit
    dependency (it was already pulled in transitively by PyJWT[crypto]).


* v1.1.6 - 2026-07-01

  - fix: OB2 Verifier(verify_key=...) again accepts a live pycryptodome/ecdsa
    key object (not only PEM bytes); the construction-time key-type guard now
    wraps the key in key_to_pem() first, matching the verification path and
    OB3Verifier.

  - fix: a malformed config.ini (bad INI syntax, an unresolvable ${...}
    reference, an encoding mismatch, or a missing/empty [paths] base) now makes
    every CLI exit cleanly with a '[!] ...' message instead of a raw traceback;
    read_config_or_exit() catches the typed ValueError from read_conf().

  - SECURITY: OB2 revocation is now honored even when the issuer publishes an
    empty/falsy reason for a revoked serial. Previously a revoked badge whose
    revocation-list reason was "", null, false, or 0 was reported VALID
    instead of REVOKED (the revocation control failed open for those entries).

  - fix: _jws.verify_block() now rejects a non-string JWS header 'alg' (e.g. a
    JSON array/object) with a clean JWSException instead of leaking a raw
    TypeError from the algorithm-allowlist membership test.

  - fix: openbadges-verifier's OB2 path now reports a clean error instead of an
    uncaught traceback for an unsupported file extension (and any other library
    exception), by catching LibOpenBadgesException at the CLI boundary.

  - fix: BadgeSigned.read_from_file() now raises AssertionFormatIncorrect
    instead of a raw TypeError/AttributeError when the JWS body decodes to a
    valid-JSON non-object (array, string, number, or null).

  - fix: openbadges-init and openbadges-publish now exit cleanly with a
    '[!] <path> already exists' message instead of a raw FileExistsError
    traceback when the target directory/output path already exists.

  - fix: OB3 credential parsing now rejects a non-string required id/name
    field (vc.id, issuer.id, credentialSubject.id, achievement.id/name) with
    a clean OB3VerificationError, instead of leaking a raw AttributeError out
    of verify() when recipient binding lower-cases the id.

  - fix: OB2 check_revocation() now raises AssertionFormatIncorrect instead
    of a raw AttributeError when the badge/issuer JSON carries a non-string
    'badge'/'issuer'/'revocationList' URL field.

  - SECURITY: _jws.verify_block() now treats an RSA private key supplied
    where a public verify key is expected as a failed signature
    (SignatureError) instead of crashing with a raw AttributeError — closes
    a remotely-triggerable crash on the badge-embedded-key OB2 fallback path.

  - fix: openbadges-signer's -M/--mail-badge now reports a clean error
    instead of crashing when config.ini sets an SMTP username without
    use_ssl=True, when the mail template file is missing/unreadable
    (OSError), or when the badge section has no 'mail' key (KeyError); the
    already-signed badge is still saved in every case.

  - fix: OB3Signer.sign() now raises ErrorSigningFile instead of leaking a
    raw jwt.exceptions.InvalidKeyError when the requested algorithm doesn't
    match the given key's type.

  - fix: OB3Verifier.extract_token_from_png() now raises ErrorParsingFile
    instead of leaking a raw UnicodeDecodeError when a PNG's "openbadges"
    iTXt chunk contains malformed UTF-8 text.

  - fix: OB2 Verifier.__init__ now raises VerifierExceptions instead of a raw
    UnknownKeyType when a supplied verify_key is not a recognizable PEM key,
    matching the guard OB3Verifier already has.

  - fix: Badge.create_from_conf() now raises PrivateKeyReadError/
    PublicKeyReadError instead of a raw FileNotFoundError/OSError when a
    configured private_key/public_key path does not exist.

  - fix: openbadges-verifier's --local option now checks the configured
    public_key file exists before opening it, instead of leaking a raw
    FileNotFoundError past the CLI's error handling.

  - fix: OB3Verifier.verify() now rejects a vc.validFrom/validUntil claim that
    parses as a timezone-naive ISO 8601 date-time instead of leaking a raw
    TypeError when compared against the tz-aware expiration check.

  - fix: JWS header parsing (verify_block) now wraps malformed/non-object
    headers into SignatureError instead of leaking a raw json.JSONDecodeError,
    and every openbadgeslib._jws exception now inherits from
    LibOpenBadgesException so it can no longer escape verify()/sign() uncaught.

  - fix: BadgeSigned.read_from_file() now raises AssertionFormatIncorrect
    instead of a raw KeyError/AttributeError when the untrusted JWS body is
    missing required fields (image, badge, uid, recipient, issuedOn).

  - fix: Verifier.get_badge_status() now catches the ValueError download_file()
    raises for a non-HTTPS revocation/issuer/badge URL and returns a clean
    SIGNATURE_ERROR instead of letting it escape uncaught.

  - SECURITY: download_file() now rejects an HTTP redirect to a non-HTTPS
    target instead of transparently following it, closing a TLS scheme-
    downgrade gap in the HTTPS-only enforcement used for the OB2 verify key,
    issuer document, and revocation list.

  - SECURITY: download_file() now caps the response body at 5 MiB, bounding
    memory use against an attacker-influenced URL serving an oversized
    response.

  - fix: ConfParser.read_conf() now raises a clean ValueError instead of a
    raw KeyError/IndexError when config.ini is missing the [paths] section,
    its 'base' key, or has an empty 'base' value.

  - fix: OB3Verifier.verify() now raises OB3VerificationError instead of a
    raw AttributeError/TypeError when the JWT 'vc' claim is not an object, or
    its 'type' field is not a string/list.

  - fix: _parse_date() now raises a clean ValueError instead of a raw
    AttributeError when a vc date claim (validFrom/validUntil) is not a
    string.

  - fix: Verifier.check_expiration() now raises AssertionFormatIncorrect
    instead of a raw TypeError when the untrusted 'expires' claim is not a
    numeric timestamp.

  - fix: extract_png_assertion() now wraps baking.extract_png() the same way
    its SVG counterpart does, raising ErrorParsingFile instead of letting a
    raw exception escape on a malformed/garbage PNG.

  - fix: Badge.__init__ now raises PrivateKeyReadError/PublicKeyReadError
    instead of a raw ValueError/binascii.Error when a configured private or
    public key file is corrupt, truncated, or mismatched with its key type.

  - fix: BadgeMail.send() now catches the ValueError smtplib raises as its
    CRLF header-injection guard for a malformed from/recipient address,
    reporting a clean mail-failure message instead of crashing.

  - fix: ConfParser.read_conf() now eagerly resolves every ${...}
    ExtendedInterpolation reference at load time, raising a clean ValueError
    for a malformed reference instead of letting a raw configparser
    exception escape lazily, deep inside a CLI tool.

  - SECURITY: OB3Verifier.verify() now independently re-validates
    vc.validUntil/validFrom against wall-clock time, instead of relying only
    on the JWT 'exp' claim, which can be decoupled from the vc-level dates
    that downstream consumers actually read. An expired or not-yet-valid
    credential is now rejected regardless of the JWT claim.

  - fix: verify_block() now wraps a malformed (invalid-length) base64url JWS
    signature segment into SignatureError instead of leaking a raw
    binascii.Error.

  - fix: Verifier.check_jws_signature() now catches the JWSException base
    class instead of only SignatureError, so a JWS header missing 'alg' or
    a completely missing verification key resolves to a clean
    SIGNATURE_ERROR instead of an uncaught exception.

  - fix: BadgeSigned.read_from_file() now raises ErrorParsingFile instead of
    a raw KeyError/TypeError when the untrusted JWS body is missing 'verify'
    or has a non-object 'verify' field.

  - fix: Verifier.check_revocation() now raises AssertionFormatIncorrect
    instead of a raw AttributeError/TypeError when the fetched badge,
    issuer, or revocation-list JSON is valid JSON but not an object.

  - fix: Signer (OB2) and OB3Signer now wrap baking.has_svg/has_png/bake_svg/
    bake_png into ErrorSigningFile instead of letting a raw
    ExpatError/AttributeError/png.FormatError escape when the carrier image
    is malformed.

  - fix: openbadges-init and openbadges-publish now restore the process
    umask in a try/finally, so a failure partway through directory/file
    creation can no longer leave the umask permanently changed for the rest
    of the process.

  - fix: normalize_recipient_id() no longer double-prefixes an
    already-mailto: URI whose scheme is spelled in a different case, and
    OB3Verifier.verify()'s recipient binding now compares mailto: URIs
    case-insensitively (DIDs remain compared exactly), so a legitimate
    recipient is no longer wrongly rejected for a casing difference.

  - fix: ConfParser.read_conf() now raises a clean ValueError instead of a
    raw configparser exception for malformed INI syntax (duplicate
    section/option, a value line with no section header).

  - fix: openbadges-verifier now enforces -l/--local and -k/--pubkey as
    mutually exclusive (as documented in the CLI reference) instead of
    silently letting -l win when both are given.


* v1.1.5 - 2026-06-30

  - SECURITY: generated private key files are now created with exclusive writes
    and owner-only permissions (0600), avoiding accidental exposure under
    permissive umasks and preventing overwrite races.

  - SECURITY: openbadges-signer rejects unsafe badge/receptor filename
    components before composing the output path, blocking traversal through
    slash, backslash, drive, dot, empty, or NUL-containing values.

  - SECURITY: SMTP authentication now requires use_ssl=True, so configured
    credentials are not sent over plaintext SMTP.

  - fix: PNG badge detection now requires an exact openbadges iTXt keyword
    instead of treating unrelated chunks that merely start with "openbadges" as
    assertions.

  - Docs: remove the remaining legacy Sphinx RST reference pages. User and
    developer documentation is maintained in the GitHub Wiki, and the API
    reference is generated for GitHub Pages.


* v1.1.4 - 2026-06-29

  - SECURITY: harden OB3 JWT-VC parsing. OpenBadgeCredential.from_jwt_payload
    now validates the structure of the untrusted "vc" claim explicitly (every
    required object/field is checked, dates must be valid ISO 8601,
    credentialSubject may be an object or non-empty array) and raises a clear
    OB3VerificationError naming the offending field instead of surfacing a raw
    KeyError/TypeError.

  - fix: the OB3 verifier's iss/sub cross-check now normalises an array-form
    credentialSubject to its first element, so a validly-signed token carrying
    credentialSubject as an array no longer escapes verify() with a raw
    AttributeError; it verifies (or fails) as an OB3VerificationError.

  - packaging: advertise Python 3.13 support (add the trove classifier; it was
    already tested in CI and stated in the README).


* v1.1.3 - 2026-06-29

  - Typing: the package now ships a py.typed marker (PEP 561) and is fully
    annotated. mypy (disallow_untyped_defs) runs clean and is enforced in CI,
    so downstream type checkers can consume openbadgeslib's types.

  - Docs: fixed stale wiki references surfaced by an audit (OB2 verifier uses
    -r/--receptor; baking.py is a shared top-level module; dev extras and the CI
    gate now list mypy/pdoc).


* v1.1.2 - 2026-06-27

  - The -d/--debug flag now actually enables DEBUG-level console logging. It was
    previously parsed but ignored in openbadges-signer; it is now wired up and
    also added to openbadges-keygenerator and openbadges-verifier.

  - Packaging metadata refreshed: README is now Markdown, and the project URLs
    point to the GitHub Wiki (documentation) and the GitHub Pages API reference
    instead of the old homepage.

  - Docs & infra: the version is single-sourced from util.__version__; CI gates
    catch doc drift (CLI flags, wiki links); an auto-generated pdoc API site and
    an auto-synced wiki were added; Changelog normalised; CONTRIBUTORS.txt
    removed (see docs/authors.rst); pending work is now tracked in GitHub Issues.


* v1.1.1 - 2026-06-27

  - OB3: recipient identifiers are normalised through one shared helper, fixing
    a DID being corrupted into 'mailto:did:...'; the verifier CLI now delegates
    recipient binding to OB3Verifier.verify() instead of re-implementing it.

  - OB3: verify() cross-checks the JWT registered claims against the vc body -
    a token whose 'iss'/'sub' disagree with the credential issuer/subject is
    rejected - and OB3VerificationError now inherits from LibOpenBadgesException
    so one except clause covers both OB2 and OB3.

  - Refactor: shared keys.alg_for_key_type and CLI config helpers
    (read_config_or_exit / resolve_badge_section / _resolve_trusted_pubkey)
    remove the boilerplate duplicated across the entrypoints; first-party code
    imports openbadgeslib.ob2 directly rather than the back-compat shims; the
    oversized verify/main/_verify_ob3 functions were decomposed.

  - Type hints added on the OB2/core byte-vs-str boundaries.

  - Tests: OB2 signer CLI, the mail subsystem and read_from_file edge cases are
    now covered; 258 tests, 92% line coverage; the whole repo (source and
    tests) is flake8-clean.

  - CI: a GitHub Actions workflow runs flake8 and the test suite on Python
    3.10-3.13 for every push and pull request.


* v1.1.0 - 2026-06-27

  - SECURITY: signature verification now pins the accepted algorithm to the
    verification key's type instead of trusting the token header. OB3Verifier
    derives the RS*/ES* family from the key and passes it to jwt.decode;
    _jws.verify_block rejects any header alg that doesn't match the key. This
    blocks cross-type confusion and any none/HMAC downgrade.

  - SECURITY: SVG badge XML is parsed with defusedxml, defusing entity-expansion
    (billion-laughs) DoS on untrusted badges. New defusedxml dependency.

  - SECURITY: iTXt PNG token extraction caps zlib decompression at 256 KB to
    stop a decompression-bomb DoS during extraction (before verification).

  - SECURITY: the OB2 verifier CLI now distinguishes a trusted operator key
    (--local/--pubkey) from the badge's own embedded key. Without a trusted key
    it reports the signature as internally-consistent-only, not "[+] correct"
    (the embedded key proves nothing about issuer identity). --pubkey now
    applies to OB2 as well as OB3.

  - SECURITY: check_revocation guards the issuer / revocation-list downloads and
    JSON parsing, raising a clean VerifierException instead of a raw error.

  - Fixed: _jws base64url padding (wrong when the length was a multiple of 4);
    BadgeSigned.get_serial_num crashed on badges read from a file (str vs bytes);
    Verifier.check_identity crashed instead of skipping when no identity was
    given; extract_svg_assertion could raise NameError masking the real error;
    unknown key/image types now fail loudly instead of UnboundLocalError.

  - Refactor: a single keys.key_to_pem() replaces three drifting copies, and a
    new openbadgeslib.baking module is the one home for the SVG/PNG carrier
    format (OB2 and OB3 share it; the OB2 fixed-offset PNG reader is gone in
    favour of the structured iTXt parser).

  - Cleanup: removed the redundant setup.py, the stale committed dist/ artifacts
    and generated MANIFEST, and several never-raised exception classes; docs now
    cover OB3 on the landing page and document the SMTP/email-badge feature;
    test import hygiene tidied. 236 tests pass, 87% line coverage.


* v1.0.2 - 2026-06-18

  - SECURITY: OB2 verification now uses the operator-supplied trusted key when
    one is given, instead of always trusting the key the badge points to. A
    forged badge can no longer self-describe its own verification key.

  - SECURITY: download_file now refuses non-HTTPS URLs by default (the verify
    key is the OB2 root of trust); pass allow_insecure=True to override.

  - Fixed expiration check: a badge is now considered expired when its
    expiration date is in the past relative to *now*, not relative to its own
    issue date (expired badges were previously reported VALID).

  - Fixed openbadges-publish: it copied no verify key and stopped after the
    first badge (it read a non-existent [keys] section). It now reads each
    badge's public_key and iterates all badge_* sections.

  - Fixed SMTP: use_ssl is parsed as a boolean (the string 'False' was truthy);
    connection-level mail errors are caught instead of crashing a successful
    sign; the example config uses 'username' (matching the code), not 'login'.

  - openbadges-keygenerator now honours a key_type (RSA/ECC) field in the badge
    profile, so ECC key pairs are reachable from the CLI.

  - OB2 sign log and log files are opened in append mode (were truncated on
    every run).

  - OB3 credentials use the W3C VC 2.0 data model (validFrom/validUntil,
    /ns/credentials/v2 context); from_jwt_payload still reads VC 1.1 fields.
    OB3Verifier.verify() gained an optional expected_recipient argument and now
    asserts the credential type; PNG token extraction parses the iTXt chunk
    structure instead of a fixed offset.

  - Robustness: unsigned PNGs raise a clear error instead of crashing;
    read_from_file raises instead of calling sys.exit; identity is normalised to
    bytes; revocation handles issuers without a revocationList.

  - Tests: added CLI/publish/keygenerator/mail/revocation coverage and removed
    the coverage omit list that hid those modules; expiration tests rewritten to
    model real timestamps.


* v1.0.1 - 2026-04-22

  - OpenBadges 3.0 support (W3C Verifiable Credentials / JWT-VC):
    new openbadgeslib.ob3 subpackage with OpenBadgeCredential, Issuer,
    Achievement data model; OB3Signer for JWT-VC signing and SVG/PNG
    baking; OB3Verifier for JWT-VC verification and token extraction.

  - OpenBadges 2.0 implementation moved to openbadgeslib.ob2 subpackage.
    Top-level badge.py / signer.py / verifier.py kept as backward-compatible
    shims so existing code continues to work unchanged.

  - All CLI tools (openbadges-keygenerator, openbadges-signer,
    openbadges-verifier, openbadges-publish) gained a -V / --ob-version
    flag to select the specification version (2 or 3, default 2).

  - openbadges-verifier: new -k / --pubkey FILE option to supply the
    public key directly when verifying OB3 badges without a config file.

  - Python 3.10+ compatibility: removed distutils (dropped in 3.12),
    packaging migrated to pyproject.toml with setuptools.build_meta.

  - Replaced abandoned pycrypto with pycryptodome >= 3.20.

  - Replaced custom bundled JWS engine (3dparty/jws/) with PyJWT[crypto]
    algorithm classes (RS256/384/512, ES256/384/512). Internal module
    moved to openbadgeslib/_jws/. Old 3dparty/ directory removed.

  - TLS security fix: download_file relies on urllib's default TLS context
    (certificate validation on) instead of the deprecated PROTOCOL_TLSv1 /
    CERT_NONE settings.

  - pypng API update: signature constant renamed, chunk tags now bytes.

  - Copyright year range updated to 2014-2026 across all source files.

  - Bug fixes: verifier signature check logic, ECC private-key detection,
    hash_email str/bytes coercion.

  - Test suite: 203 tests, 89% line coverage.


* v0.4.2

  - Adding support to verifying external openbadges.

  - Adding a new parameter to show assertion before verifying.

  - OpenBadges URLs are verified before the signing process.


* v0.4

  - Support for PNG. The library can now verify and sign OpenBadges in PNG
    format.

  - Support for sending badges via mail.

  - Badge signatures are registered in a log file.

  - Mail code now supports SMTP auth. The config file needs modifications;
    see the example.


* v0.3

  - The email is no longer encoded in the filename when creating a new badge.

  - When creating a badge, "-o" is optional.

  - When specified, "-o" is supposed to be a directory.

  - We can specify "-E" or "--no-evidence" when creating a new badge.

  - When creating a new badge we now need to choose EXPLICITLY between
    providing evidence or not.

  - Badge sections in the INI file must now be prefixed with "badge_".
    UPGRADE: if your badge is called "ponente2014", rename it to
    "badge_ponente2014".

  - Each badge can now have a different key.
    UPGRADE: move your current key configuration to the badge section,
    renaming "private" to "private_key" and "public" to "public_key".
    UPGRADE: "openbadges-keygenerator -g" now requires a badge name.

  - "openbadges-verifier" changes its parameter from "-lk" to "-l" and now
    needs the name of the badge to locate its public key.

  - "openbadges-verifier" now accepts a parameter -x to specify the expiration
    for a badge.

  - "openbadges-verifier" now checks the revocation status and the expiration
    dates of badges.


* v0.2.1 - 2014-12-16

  - config.ini now allows configuring the badge json url and image url.

  - Signer now uses randomly salted emails by default in assertions.

  - Compatibility with more SVG formats.

  - Documentation in the "docs" directory.


* v0.2 - 2014-12-10

  - "openbadges-init".

  - New configuration format. If you are using the 0.1 format, you must
    migrate by hand.

  - Tests.

  - Use proper logging instead of simple "print" calls.

  - Massive cleanup of internal imports.

  - Do not change "sys.path".


* v0.1 - 2014-12-01

  - Initial release.
