-
Notifications
You must be signed in to change notification settings - Fork 1
Security Model
This page explains what openbadgeslib actually proves when it verifies a badge, and how to drive it so a positive verdict means something. The short version: a valid signature alone never establishes who issued a badge — you must supply a trusted key. See also Signing and Verification and the CLI Reference.
A cryptographic signature only proves that whoever holds the matching private key signed the badge. It says nothing about whether that key belongs to the issuer you trust. An OB2 badge can even point at its own verification key (badge.source.pub_key, downloaded from a URL baked inside the untrusted badge), so trusting that key blindly would let an attacker self-sign forgeries and have them "verify".
openbadgeslib therefore distinguishes a trusted operator key from the badge-embedded key:
- When you pass a trusted public key (
--localto resolve it from your config, or--pubkey FILEto point at a PEM directly), OB2 verification runs against that key. A success is a real positive verdict —[+]. - When you pass no trusted key, OB2 falls back to the key the badge itself points to. The signature can still be checked for internal consistency, but the verdict is downgraded to internally consistent only and reported with the
[~]warning marker, because the embedded key proves nothing about issuer identity.
In ob2/verifier.py this is the Verifier.check_jws_signature logic:
verify_key = self.verify_key if self.verify_key is not None else badge.source.pub_keyFor a verdict you can rely on, always supply your own key:
# OB2 — trusted key from the badge's config section ([badge_python2026])
openbadges-verifier -i badge_signed.svg -r recipient@example.org --local python2026
# OB2 or OB3 — trusted key straight from a PEM file
openbadges-verifier -i badge_signed.svg -r recipient@example.org --pubkey ./issuer_pub.pemFor OB3, OB3Verifier is constructed from the public key you give it, so there is no "embedded key" fallback to worry about — but the same principle holds: the key you hand it is your root of trust.
A classic attack on JWT/JWS is to rewrite the token header to alg: none or to an HMAC algorithm (HS256), tricking a verifier into accepting an unsigned or attacker-keyed token (an algorithm-confusion downgrade). openbadgeslib refuses to let the token header choose the algorithm.
The accepted algorithm is derived from the key type, not read from the token. In ob3/verifier.py:
_ALGORITHMS_BY_KEY_TYPE = {
KeyType.RSA: ['RS256', 'RS384', 'RS512'],
KeyType.ECC: ['ES256', 'ES384', 'ES512'],
}OB3Verifier detects the key type, restricts jwt.decode to that family, and additionally rejects any header alg outside the allowed set before decoding. An RSA key can never validate an ES* token and vice-versa; none and HMAC are never in the allowed list. The OB2 _jws.verify_block enforces the same pinning. This blocks none/HMAC downgrades and cross-type confusion in one step.
A badge file (and the URLs inside it) is attacker-controlled until proven otherwise. The library hardens every point where untrusted bytes are read or fetched — and crucially, the parsing/decompression hardening runs before any signature check, so a malformed badge cannot exhaust resources just by being parsed.
Verification fetches the badge JSON, the issuer profile and the revocation list over the network. util.download_file rejects any non-HTTPS URL by default, because an unauthenticated channel would let an active network attacker substitute their own verification key and forge badges:
if u.scheme != 'https':
if not allow_insecure:
raise ValueError('Refusing to download ... HTTPS is required ...')TLS certificate validation is on (urllib's default context), downloads time out after 30 seconds, and plain HTTP is only ever used if a caller explicitly passes allow_insecure=True.
Baked SVG badges are XML. Parsing untrusted XML with a naive parser is vulnerable to entity-expansion ("billion laughs") denial-of-service. The baking module parses every SVG through defusedxml.minidom.parseString, which disables the entity-expansion vectors:
from defusedxml.minidom import parseStringThis applies to extraction (extract_svg), the presence check (has_svg) and baking itself.
A baked PNG stores the token in an openbadges iTXt chunk, which may be zlib-compressed. A crafted "zip bomb" could inflate a few KB into gigabytes. baking.extract_png caps decompression at 256 KB and raises DecompressionLimitExceeded rather than allocating without limit:
MAX_ITXT_DECOMPRESSED = 256 * 1024
# ... _bounded_inflate raises DecompressionLimitExceeded past the capThe iTXt structure is parsed properly (keyword, compression flag/method, language tag, translated keyword, then text) instead of trusting a fixed byte offset, so a malformed chunk is rejected rather than misread.
A cryptographically valid signature is necessary but not sufficient. For OB2, Verifier.get_badge_status only returns VALID after the following checks also pass.
A badge is expired when its expiration timestamp is in the past relative to the current time, not relative to its own issue date. From ob2/verifier.py:
if badge.expiration < time():
return strftime(...) # expiredAn expired badge is reported with status EXPIRED. (Badges with no expiration are simply never expired.)
OB2 revocation is checked by downloading the badge JSON, following its issuer URL to the issuer profile, and reading the optional revocationList. If the issuer publishes one and the badge's serial number appears in it, verification returns REVOKED with the published reason:
revocation_url = issuer.get('revocationList') # optional in OB 2.0
if not revocation_url:
return None # issuer publishes no revocationsNote the trust implication: revocation is fetched from the issuer's host over HTTPS. The result is only as trustworthy as that host and its TLS. An absent revocationList means "no revocations published", which is treated as not-revoked. All issuer/revocation downloads and JSON parses are guarded — a missing or malformed document raises a clean library error instead of a raw crash.
The signature does not, by itself, bind a badge to a particular recipient — you must request that check explicitly, otherwise it is skipped (not silently failed).
-
OB2: pass
-i / --identity(an email).check_identityrecomputessha256$ + hash_email(identity, salt)and compares it to the badge's identity hash; on mismatch the status isIDENTITY_ERROR. With no identity given, the recipient check is skipped. -
OB3: pass
expected_recipienttoOB3Verifier.verify(). It is normalised (a bare email gains amailto:scheme, a DID is left untouched) and compared tocredentialSubject.id. If you do not pass it, you must comparecredential.recipient_idyourself.
A signed JWT-VC could pair valid registered claims with a mismatched credential body. After verifying the signature, OB3Verifier._build_credential cross-checks the JWT's iss/sub against the embedded VC:
if iss is not None and iss != (vc.get("issuer") or {}).get("id"):
raise OB3VerificationError("JWT 'iss' does not match the credential issuer")
if sub is not None and sub != (vc.get("credentialSubject") or {}).get("id"):
raise OB3VerificationError("JWT 'sub' does not match the credentialSubject id")It also requires the vc claim to be present and to carry the OpenBadgeCredential type, so an OB2 JWS token (which has no vc claim) is rejected with a clear message rather than misinterpreted.
-
Always supply a trusted key (
--localor--pubkey) when you need a real positive verdict. Treat a[~]"internally consistent only" result as unverified provenance, never as proof of issuer identity. - Obtain the issuer's public key out of band (a channel you trust), not from the badge.
-
Pass the recipient (
-ifor OB2,expected_recipientfor OB3) whenever the badge is meant for a specific person; otherwise the binding is your responsibility. -
Keep
allow_insecureoff. Do not downgrade downloads to plain HTTP. -
Trust revocation only as far as you trust the issuer host that serves the
revocationList.
See the CLI Reference for the exact flags and Signing and Verification for the end-to-end workflow.
openbadgeslib · LGPLv3 (library) / BSD (CLI) · Issues
Getting Started
Concepts
Reference
Guides
Project