-
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'],
KeyType.ED25519: ['EdDSA'],
}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, and an Ed25519 key accepts only EdDSA; 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. A redirect to a non-HTTPS target is also rejected — an HTTPS URL can't be silently downgraded via a 302 — and the response body is capped at 5 MiB to bound memory use against an oversized or attacker-influenced response.
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 strict OB 2.0 OB2Verifier.verify (and, equivalently, the legacy Verifier.get_badge_status) only accepts a badge 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. Strict OB 2.0 (ob2/verifier.py) compares ISO 8601 datetimes; the legacy -V 1 path (ob1/verifier.py) compares Unix timestamps:
if badge.expiration < time():
return strftime(...) # expiredAn expired badge is reported with status EXPIRED. (Badges with no expiration are simply never expired.)
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 appears in it — the assertion id inside the strict OB 2.0 revokedAssertions array, or the serial number in the legacy -V 1 flat map — verification fails as revoked:
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 OB3 equivalent is credentialStatus (W3C Bitstring Status List v1.0, or the legacy StatusList2021). It is opt-in — OB3Verifier.verify(..., check_status=True) or the --check-status CLI flag — because verification is otherwise fully offline. When enabled, each status entry's statusListCredential is fetched over HTTPS, its encodedList is base64url-decoded and GZIP-inflated under a size cap (a bounded inflate, so a crafted status list cannot exhaust memory), and the bit at statusListIndex is read (MSB-first). A set revocation/suspension bit fails verification.
The check is fail-closed: if the status list cannot be fetched or parsed, verification fails rather than passing. It verifies the published status bit only — it does not verify the status-list credential's own signature, which is a separate trust chain (often a different key); a deployment needing that guarantee must verify the status-list credential independently. (The lists this library publishes with openbadges-publish -V 3 are signed — as JWT-VCs, with the badge's own key — so such a deployment can check them with the badge's public key.)
On the issuer side, the index each credential occupies is recorded in a private per-badge registry under [paths] base_status, written with a 0o077 umask and atomically (temp file + rename). Treat it like the signer log: it names recipients, and losing it makes the outstanding credentials unrevocable. Indices are allocated randomly (secrets), per the spec's privacy recommendation — sequential allocation would leak issuance order and volume to anyone reading the public list.
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
-r / --receptor(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.
OB 3.0 here is a native VC-JWT (§8.2.4.1): the JWT payload is the credential (its members at the top level, with no vc claim wrapper). A signed token could still pair the registered claims with a mismatched credential body, so after verifying the signature OB3Verifier._build_credential requires the mandatory claims and binds them to the credential (iss and nbf are required — their absence fails, it is not merely a when-present cross-check):
iss = payload.get("iss")
if iss is None:
raise OB3VerificationError("JWT payload is missing the required 'iss' claim")
if iss != _claim_object_id(vc.get("issuer")): # issuer as object, IRI, or array
raise OB3VerificationError("JWT 'iss' does not match the credential issuer")
if payload.get("nbf") is None:
raise OB3VerificationError("JWT payload is missing the required 'nbf' claim")
sub = payload.get("sub")
subject_id = _claim_object_id(vc.get("credentialSubject"))
if subject_id is not None and sub is None: # sub required when subject has an id
raise OB3VerificationError("JWT payload is missing the required 'sub' claim")
if sub is not None and sub != subject_id:
raise OB3VerificationError("JWT 'sub' does not match the credentialSubject id")The payload's top-level type must include VerifiableCredential and either OpenBadgeCredential or its alias AchievementCredential; a token carrying neither (e.g. an OB2 JWS assertion, which is not a native VC-JWT) is rejected with a clear message rather than misinterpreted.
OB3Verifier.for_issuer_did(did) (and the verifier's --resolve-did flag) turn an issuer DID into a verification key. Two methods are supported, each with a different trust anchor:
- did:key is self-certifying: the public key is encoded directly in the identifier (multibase base58btc of a multicodec-prefixed key). Resolving it needs no network and no external trust — the key is the identifier.
-
did:web trusts the host's DNS and TLS: the DID document is fetched from
https://<host>/.well-known/did.json(or a path-baseddid.json) using the same HTTPS-only, size-capped downloader, and its first verification method (publicKeyJwkorpublicKeyMultibase) is used.
When --resolve-did is used, the DID is read from the still-unverified token and resolved; the signature is then checked against the resolved key. Because the DID is the issuer's own claimed identity, this is legitimate trust anchoring for did:key/did:web — but it is only as strong as that method's anchor (nothing for did:key beyond the key itself; DNS+TLS for did:web). Ledger/anchored methods (did:ion, did:ethr, …) are not resolved.
Note a resolver limitation that matters when publishing did.json with openbadges-publish -V 3: the document lists one verification method per badge, but this resolver (like several others in the wild) only reads verificationMethod[0]. --resolve-did therefore only verifies credentials signed with the first listed badge's key; for the others, pin the key with -k/-l.
Beyond the claim cross-checks, the credential body itself is untrusted input, so from_jwt_payload validates its structure explicitly: @context must be the required VC 2.0 + OB v3p0 pair; issuer must be a Profile object or a string IRI; credentialSubject and achievement must be JSON objects; the required identity fields (id, issuer.id, achievement.id/name) must be present and non-empty; credentialSubject.id is optional (identity may travel via identifier); dates must be valid ISO 8601; and credentialSubject may be a single object or a non-empty array. A malformed credential is rejected with an OB3VerificationError that names the offending field, never a raw KeyError/TypeError.
OB3LdpVerifier (the [ldp] extra) verifies the other proof format OB 3.0 allows: a JSON-LD credential with an embedded W3C Data Integrity proof; OB3LdpSigner issues them with the same canonicalization core. The algorithm follows the vc-di-eddsa Recommendation — RDFC-1.0 canonicalization of the unsecured document and of the proof configuration, SHA-256 hashing, Ed25519 signature over the combined hashes — and both directions are validated byte-for-byte against the official W3C test vectors. Security properties specific to this path:
-
JSON-LD contexts are never fetched from the network. Canonicalization resolves every
@contexta credential names; fetching them remotely would be an SSRF vector and would let a context host silently change what a signature covers. The exact context documents (VC 2.0, the published OB v3p0 revisions,data-integrity/v2,multikey/v1) ship pinned inside the wheel with recorded provenance, behind an exact-match allowlist — a credential naming any other context URL fails closed. - Documents are capped at 256 KiB before canonicalization: RDF canonicalization cost grows super-linearly on crafted blank-node graphs ("poison graphs"), and the document is attacker-supplied.
-
Proof validation is fail-closed: exactly one
DataIntegrityProofwith a supported cryptosuite is required;proofPurposemust beassertionMethod;proofValuemust be a 64-byte multibase signature; an expired proof or credential is rejected; an unknown cryptosuite (e.g.ecdsa-sd-2023, not yet supported) is rejected naming the supported ones. -
Key binding mirrors the JWT path: an operator-pinned key wins and must be Ed25519 for this cryptosuite. Without a pinned key, the key comes from
proof.verificationMethod, resolved to the exact method the proof names (unlike the bare-DID resolver above, which readsverificationMethod[0]); if the credential names a DID issuer, the method must belong to that DID — otherwise any keyholder could re-sign someone else's credential. A did:key is self-asserted and reported as untrusted by the CLI, exactly like the JWT--resolve-didcase. -
Issuance is fail-closed too:
OB3LdpSignerrejects non-Ed25519 keys at construction, refuses to add a second proof to an already-secured document (the verifier would reject the ambiguity), signs only against the bundled context allowlist, and derives the did:key verification method from the private key's public half so a stalepublic_keyfile cannot produce an unverifiable badge. With a did:web issuer the proof names the exactdid:web:…#badge_Nmethodopenbadges-publish -V 3publishes. -
Status lists stay VC-JWT:
openbadges-publish -V 3signs Bitstring Status List credentials as JWT-VCs regardless of the badge's proof format, andcheck_statuson a Data Integrity credential consumes them unchanged — the status-bit trust chain is identical for both proof formats.
The signature covers the assertion / credential (recipient, achievement, issuer, dates, URLs), not the bytes of the carrier image. This is by design in OpenBadges: the embedded assertion is the canonical, verifiable artifact, and a correct consumer reads and validates those signed fields — it does not trust the surrounding pixels.
A practical consequence: a valid assertion can be lifted from one badge image and embedded into a different image, and it will still verify. That is not a forgery — the recipient, achievement and issuer are unchanged and still validly signed; only the decorative image differs. Nothing in the signed claims can be altered without breaking the signature.
Binding the signature to the image bytes (e.g. hashing the pixels into the payload) is deliberately not done: it is not part of the OB 2.0/3.0 specifications (other verifiers would ignore it, hurting interoperability) and it is fragile — baking the token changes the image, and any later re-encoding, optimisation or metadata edit would break the hash and flag legitimate badges as tampered. Trust the assertion, not the picture.
-
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 (
-rfor 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