openbadgeslib 1.2.0__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/Changelog.txt +76 -0
- {openbadgeslib-1.2.0/openbadgeslib.egg-info → openbadgeslib-2.0.0}/PKG-INFO +3 -2
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/README.md +2 -1
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/baking.py +37 -24
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/config.ini.example +1 -1
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/confparser.py +16 -4
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/credential.py +84 -26
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/signer.py +43 -11
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/status.py +30 -2
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/verifier.py +68 -27
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_verifier.py +33 -7
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/util.py +98 -11
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0/openbadgeslib.egg-info}/PKG-INFO +3 -2
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_cli_json.py +86 -1
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_confparser.py +50 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_credential.py +13 -7
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_did.py +69 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_signer.py +27 -8
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_status.py +46 -11
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_verifier.py +98 -24
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_util.py +76 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/LICENSE.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/MANIFEST.in +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/docs/README.md +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/badge.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/badge.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/verifier.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_publish.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_signer.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/signer.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/verifier.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/pyproject.toml +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/setup.cfg +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/config1.ini +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/conftest.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/runtests.sh +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_badge_io.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_docs.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_jws.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_signer_operation.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_verify_operation.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,82 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v2.0.0 - 2026-07-01
|
|
8
|
+
|
|
9
|
+
- BREAKING (OB3): OpenBadges 3.0 credentials are now secured with the native
|
|
10
|
+
OB 3.0 VC-JWT (spec §8.2): the JWT payload IS the credential (its members at
|
|
11
|
+
the top level, no 'vc' claim wrapper), validFrom maps to 'nbf' (there is no
|
|
12
|
+
'iat'), and the JOSE header carries the issuer's public key as a 'jwk'.
|
|
13
|
+
Tokens issued by 1.x (the VCDM-1.1-style 'vc'-wrapper) are NOT compatible.
|
|
14
|
+
|
|
15
|
+
- BREAKING (OB3): baked images use the OB 3.0 document-format identifiers —
|
|
16
|
+
the PNG iTXt keyword 'openbadgecredential' and the SVG element
|
|
17
|
+
<openbadges:credential> (namespace https://purl.imsglobal.org/ob/v3p0). OB3
|
|
18
|
+
images baked by 1.x (OB2 identifiers) are not read by this verifier.
|
|
19
|
+
|
|
20
|
+
- OB3 verifier now accepts credentials that are valid per the spec schema but
|
|
21
|
+
were previously rejected: the AchievementCredential type alias, an issuer
|
|
22
|
+
given as a string IRI (not only a Profile object), and a credentialSubject
|
|
23
|
+
without an 'id' (identity conveyed via 'identifier').
|
|
24
|
+
|
|
25
|
+
- OB3 verifier now enforces required structure it previously ignored: the
|
|
26
|
+
@context (VC 2.0 + OB v3p0 pair) is validated, and the registered claims
|
|
27
|
+
'iss' and 'nbf' are required (with 'sub' required when the subject has an id).
|
|
28
|
+
|
|
29
|
+
- OB3 credentialStatus now honors statusPurpose: 'suspension' is reported
|
|
30
|
+
distinctly from 'revocation', a non-revocation/suspension purpose (e.g.
|
|
31
|
+
'message') no longer fails verification, and the entry's purpose is
|
|
32
|
+
cross-checked against the fetched status list's declared purpose.
|
|
33
|
+
|
|
34
|
+
- OpenBadges 2.0 is unchanged; only the OB3 path is affected.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
* v1.3.0 - 2026-07-01
|
|
38
|
+
|
|
39
|
+
- SECURITY: download_file() now blocks server-side request forgery. The URLs
|
|
40
|
+
it fetches are attacker-influenced (an OB2 badge/issuer/revocationList URL,
|
|
41
|
+
an OB3 did:web host, an OB3 credentialStatus list), so it now resolves the
|
|
42
|
+
destination host and refuses any private, loopback, link-local, reserved,
|
|
43
|
+
multicast, unspecified or carrier-grade-NAT (100.64/10) address; the check
|
|
44
|
+
is re-applied to redirect targets. Previously a crafted badge could steer a
|
|
45
|
+
verifier into GETting cloud-metadata endpoints or internal hosts. An
|
|
46
|
+
allow_private=True opt-out is available for private deployments.
|
|
47
|
+
|
|
48
|
+
- SECURITY: OB3Verifier.for_issuer_did() now binds the credential to the
|
|
49
|
+
anchored DID — verify() requires the credential's issuer id to equal the
|
|
50
|
+
DID whose key was resolved. did:web is not self-certifying, so without this
|
|
51
|
+
a credential signed by the resolved key could claim a different (trusted)
|
|
52
|
+
issuer and be accepted. Verifiers built directly from a public key are
|
|
53
|
+
unchanged (the caller vouches for the key).
|
|
54
|
+
|
|
55
|
+
- SECURITY: openbadges-verifier --json exit status now reflects issuer trust,
|
|
56
|
+
not just signature validity: 0 = valid AND issuer-trusted, 2 = valid
|
|
57
|
+
signature but untrusted issuer (an OB2 badge-embedded key or a self-asserted
|
|
58
|
+
did:key), 1 = failure. In 1.2.0 a valid-but-untrusted badge exited 0, so CI
|
|
59
|
+
gating on the exit code accepted signatures that do not prove issuer
|
|
60
|
+
identity. NOTE: this changes the exit code for the valid-but-untrusted case
|
|
61
|
+
from 0 to 2; the JSON body (valid/trusted/reason) is unchanged.
|
|
62
|
+
|
|
63
|
+
- SECURITY: OB3 --resolve-did on a self-asserted did:key is now reported
|
|
64
|
+
trusted:false (with a warning), mirroring OB2's badge-embedded-key case;
|
|
65
|
+
only an operator-supplied key or a DNS/TLS-anchored did:web is trusted.
|
|
66
|
+
|
|
67
|
+
- fix: an OB3 Bitstring Status List with statusSize > 1 (multi-bit entries)
|
|
68
|
+
now fails closed instead of reading the wrong bit, which could have
|
|
69
|
+
reported a revoked credential as valid.
|
|
70
|
+
|
|
71
|
+
- fix: confparser now resolves a relative [paths] base against the directory
|
|
72
|
+
containing the config file (not the process CWD). Previously only a bare
|
|
73
|
+
'.' was handled and its value replaced wholesale, silently dropping the
|
|
74
|
+
suffix of './data' or '../shared' and misplacing key/log/image trees. A '$'
|
|
75
|
+
in the config directory path is handled correctly.
|
|
76
|
+
|
|
77
|
+
- docs: Ed25519 (EdDSA) support is now documented across README and the wiki
|
|
78
|
+
(key types, algorithms, keygenerator key_type, config example); the --json
|
|
79
|
+
exit-status scheme is documented; and stale/inaccurate crypto-library and
|
|
80
|
+
dependency notes were corrected.
|
|
81
|
+
|
|
82
|
+
|
|
7
83
|
* v1.2.0 - 2026-07-01
|
|
8
84
|
|
|
9
85
|
- feat: openbadges-verifier gained a --json flag for machine-readable output.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: A library to sign and verify OpenBadges
|
|
5
5
|
Author-email: Luis González Fernández <luisgf@luisgf.es>, Jesús Cea Avión <jcea@jcea.es>
|
|
6
6
|
License: LGPLv3
|
|
@@ -56,7 +56,7 @@ serialisation) and **OpenBadges 3.0** (W3C Verifiable Credentials / JWT-VC).
|
|
|
56
56
|
- Sign badge images (SVG and PNG) with a JWS assertion (OB 2.0)
|
|
57
57
|
- Issue and verify OpenBadges 3.0 JWT-VC credentials
|
|
58
58
|
- Bake OB 3.0 JWT tokens into SVG and PNG badge images
|
|
59
|
-
- RSA 2048-bit (RS256)
|
|
59
|
+
- RSA 2048-bit (RS256), ECC NIST P-256 (ES256), and Ed25519 (EdDSA) key support
|
|
60
60
|
- SHA-256 hashed recipient identity with salt (OB 2.0)
|
|
61
61
|
- Expiration and revocation checking
|
|
62
62
|
- Five command-line tools included
|
|
@@ -68,6 +68,7 @@ serialisation) and **OpenBadges 3.0** (W3C Verifiable Credentials / JWT-VC).
|
|
|
68
68
|
- [ecdsa](https://pypi.org/project/ecdsa/) >= 0.19
|
|
69
69
|
- [pypng](https://pypi.org/project/pypng/) >= 0.20220715.0
|
|
70
70
|
- [PyJWT[crypto]](https://pypi.org/project/PyJWT/) >= 2.8
|
|
71
|
+
- [cryptography](https://pypi.org/project/cryptography/) >= 42
|
|
71
72
|
- [defusedxml](https://pypi.org/project/defusedxml/) >= 0.7
|
|
72
73
|
|
|
73
74
|
## Installation
|
|
@@ -15,7 +15,7 @@ serialisation) and **OpenBadges 3.0** (W3C Verifiable Credentials / JWT-VC).
|
|
|
15
15
|
- Sign badge images (SVG and PNG) with a JWS assertion (OB 2.0)
|
|
16
16
|
- Issue and verify OpenBadges 3.0 JWT-VC credentials
|
|
17
17
|
- Bake OB 3.0 JWT tokens into SVG and PNG badge images
|
|
18
|
-
- RSA 2048-bit (RS256)
|
|
18
|
+
- RSA 2048-bit (RS256), ECC NIST P-256 (ES256), and Ed25519 (EdDSA) key support
|
|
19
19
|
- SHA-256 hashed recipient identity with salt (OB 2.0)
|
|
20
20
|
- Expiration and revocation checking
|
|
21
21
|
- Five command-line tools included
|
|
@@ -27,6 +27,7 @@ serialisation) and **OpenBadges 3.0** (W3C Verifiable Credentials / JWT-VC).
|
|
|
27
27
|
- [ecdsa](https://pypi.org/project/ecdsa/) >= 0.19
|
|
28
28
|
- [pypng](https://pypi.org/project/pypng/) >= 0.20220715.0
|
|
29
29
|
- [PyJWT[crypto]](https://pypi.org/project/PyJWT/) >= 2.8
|
|
30
|
+
- [cryptography](https://pypi.org/project/cryptography/) >= 42
|
|
30
31
|
- [defusedxml](https://pypi.org/project/defusedxml/) >= 0.7
|
|
31
32
|
|
|
32
33
|
## Installation
|
|
@@ -34,8 +34,16 @@ from typing import List, Optional, Tuple, Union
|
|
|
34
34
|
from defusedxml.minidom import parseString
|
|
35
35
|
from png import Reader, signature as _png_signature
|
|
36
36
|
|
|
37
|
+
# OB 2.0 document-format identifiers.
|
|
37
38
|
ITXT_KEYWORD = b'openbadges'
|
|
38
39
|
SVG_ELEMENT = 'openbadges:assertion'
|
|
40
|
+
SVG_NS = 'http://openbadges.org'
|
|
41
|
+
|
|
42
|
+
# OB 3.0 document-format identifiers (differ from OB 2.0). Selected via the
|
|
43
|
+
# keyword-only args below so OB2 and OB3 bake/extract their own carriers.
|
|
44
|
+
ITXT_KEYWORD_OB3 = b'openbadgecredential'
|
|
45
|
+
SVG_ELEMENT_OB3 = 'openbadges:credential'
|
|
46
|
+
SVG_NS_OB3 = 'https://purl.imsglobal.org/ob/v3p0'
|
|
39
47
|
|
|
40
48
|
# Maximum bytes a compressed iTXt token is allowed to inflate to. A JWS/JWT-VC
|
|
41
49
|
# is a few KB; this cap stops a crafted zlib bomb from exhausting memory during
|
|
@@ -47,9 +55,9 @@ class DecompressionLimitExceeded(Exception):
|
|
|
47
55
|
"""Raised when a compressed iTXt token inflates beyond the allowed size."""
|
|
48
56
|
|
|
49
57
|
|
|
50
|
-
def _split_openbadges_itxt(data: bytes) -> Optional[bytes]:
|
|
51
|
-
|
|
52
|
-
if sep != b'\x00' or
|
|
58
|
+
def _split_openbadges_itxt(data: bytes, keyword: bytes = ITXT_KEYWORD) -> Optional[bytes]:
|
|
59
|
+
chunk_keyword, sep, rest = data.partition(b'\x00')
|
|
60
|
+
if sep != b'\x00' or chunk_keyword != keyword or len(rest) < 2:
|
|
53
61
|
return None
|
|
54
62
|
return rest
|
|
55
63
|
|
|
@@ -66,14 +74,16 @@ def _bounded_inflate(data: bytes, limit: int = MAX_ITXT_DECOMPRESSED) -> bytes:
|
|
|
66
74
|
|
|
67
75
|
# ── SVG ─────────────────────────────────────────────────────────────────────
|
|
68
76
|
|
|
69
|
-
def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None, *,
|
|
78
|
+
element: str = SVG_ELEMENT, namespace: str = SVG_NS) -> bytes:
|
|
79
|
+
"""Return *image_bytes* with an ``<element verify=token>`` node (and an
|
|
80
|
+
optional XML comment) appended to the root ``<svg>``. *element*/*namespace*
|
|
81
|
+
default to the OB 2.0 identifiers; OB 3.0 passes its own."""
|
|
72
82
|
svg_doc = parseString(image_bytes)
|
|
73
83
|
try:
|
|
74
84
|
svg_tag = svg_doc.getElementsByTagName('svg').item(0)
|
|
75
|
-
node = svg_doc.createElement(
|
|
76
|
-
node.attributes['xmlns:openbadges'] =
|
|
85
|
+
node = svg_doc.createElement(element)
|
|
86
|
+
node.attributes['xmlns:openbadges'] = namespace
|
|
77
87
|
node.attributes['verify'] = token
|
|
78
88
|
svg_tag.appendChild(node)
|
|
79
89
|
if comment:
|
|
@@ -83,24 +93,24 @@ def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None) -> b
|
|
|
83
93
|
svg_doc.unlink()
|
|
84
94
|
|
|
85
95
|
|
|
86
|
-
def has_svg(image_bytes: bytes) -> bool:
|
|
87
|
-
"""Return True if *image_bytes* already carries an
|
|
96
|
+
def has_svg(image_bytes: bytes, *, element: str = SVG_ELEMENT) -> bool:
|
|
97
|
+
"""Return True if *image_bytes* already carries an *element* node."""
|
|
88
98
|
svg_doc = parseString(image_bytes)
|
|
89
99
|
try:
|
|
90
|
-
return bool(svg_doc.getElementsByTagName(
|
|
100
|
+
return bool(svg_doc.getElementsByTagName(element))
|
|
91
101
|
finally:
|
|
92
102
|
svg_doc.unlink()
|
|
93
103
|
|
|
94
104
|
|
|
95
|
-
def extract_svg(image_bytes: bytes) -> Optional[str]:
|
|
96
|
-
"""Return the embedded token string, or None if there is no
|
|
105
|
+
def extract_svg(image_bytes: bytes, *, element: str = SVG_ELEMENT) -> Optional[str]:
|
|
106
|
+
"""Return the embedded token string, or None if there is no *element* node.
|
|
97
107
|
|
|
98
108
|
Raises on malformed XML (left to the caller to map to its own error type).
|
|
99
109
|
"""
|
|
100
110
|
svg_doc = None
|
|
101
111
|
try:
|
|
102
112
|
svg_doc = parseString(image_bytes)
|
|
103
|
-
nodes = svg_doc.getElementsByTagName(
|
|
113
|
+
nodes = svg_doc.getElementsByTagName(element)
|
|
104
114
|
if not nodes:
|
|
105
115
|
return None
|
|
106
116
|
return nodes[0].attributes['verify'].nodeValue
|
|
@@ -124,28 +134,31 @@ def _serialize_png(chunks: List[Tuple[Union[str, bytes], bytes]]) -> bytes:
|
|
|
124
134
|
return out
|
|
125
135
|
|
|
126
136
|
|
|
127
|
-
def bake_png(image_bytes: bytes, token: str, text_comment: Optional[str] = None
|
|
128
|
-
|
|
129
|
-
|
|
137
|
+
def bake_png(image_bytes: bytes, token: str, text_comment: Optional[str] = None, *,
|
|
138
|
+
keyword: bytes = ITXT_KEYWORD) -> bytes:
|
|
139
|
+
"""Return *image_bytes* with the token stored in a *keyword* iTXt chunk (and
|
|
140
|
+
an optional ``tEXt`` comment chunk) inserted before IEND. *keyword* defaults
|
|
141
|
+
to the OB 2.0 identifier; OB 3.0 passes ``openbadgecredential``."""
|
|
130
142
|
chunks = list(Reader(bytes=image_bytes).chunks())
|
|
131
|
-
itxt_data =
|
|
143
|
+
itxt_data = keyword + pack('BBBBB', 0, 0, 0, 0, 0) + token.encode('utf-8')
|
|
132
144
|
chunks.insert(len(chunks) - 1, ('iTXt', itxt_data))
|
|
133
145
|
if text_comment:
|
|
134
146
|
chunks.insert(len(chunks) - 1, ('tEXt', text_comment.encode('utf-8')))
|
|
135
147
|
return _serialize_png(chunks)
|
|
136
148
|
|
|
137
149
|
|
|
138
|
-
def has_png(image_bytes: bytes) -> bool:
|
|
139
|
-
"""Return True if *image_bytes* already carries
|
|
150
|
+
def has_png(image_bytes: bytes, *, keyword: bytes = ITXT_KEYWORD) -> bool:
|
|
151
|
+
"""Return True if *image_bytes* already carries a *keyword* iTXt chunk."""
|
|
140
152
|
for tag, data in Reader(bytes=image_bytes).chunks():
|
|
141
153
|
tag_str = tag.decode('ascii') if isinstance(tag, bytes) else tag
|
|
142
|
-
if tag_str == 'iTXt' and _split_openbadges_itxt(data) is not None:
|
|
154
|
+
if tag_str == 'iTXt' and _split_openbadges_itxt(data, keyword) is not None:
|
|
143
155
|
return True
|
|
144
156
|
return False
|
|
145
157
|
|
|
146
158
|
|
|
147
|
-
def extract_png(image_bytes: bytes, max_decompressed: int = MAX_ITXT_DECOMPRESSED
|
|
148
|
-
|
|
159
|
+
def extract_png(image_bytes: bytes, max_decompressed: int = MAX_ITXT_DECOMPRESSED, *,
|
|
160
|
+
keyword: bytes = ITXT_KEYWORD) -> Optional[str]:
|
|
161
|
+
"""Return the embedded token string, or None if there is no *keyword*
|
|
149
162
|
iTXt chunk.
|
|
150
163
|
|
|
151
164
|
Parses the iTXt structure (keyword, compression flag/method, language tag,
|
|
@@ -159,7 +172,7 @@ def extract_png(image_bytes: bytes, max_decompressed: int = MAX_ITXT_DECOMPRESSE
|
|
|
159
172
|
if tag_str != 'iTXt':
|
|
160
173
|
continue
|
|
161
174
|
|
|
162
|
-
rest = _split_openbadges_itxt(data)
|
|
175
|
+
rest = _split_openbadges_itxt(data, keyword)
|
|
163
176
|
if rest is None:
|
|
164
177
|
continue
|
|
165
178
|
compression_flag = rest[0]
|
|
@@ -44,7 +44,7 @@ verify_key = https://www.issuer.badge/issuer/badge_1/verify_rsa_key.pem
|
|
|
44
44
|
badge = https://www.issuer.badge/issuer/badge_1/badge.json
|
|
45
45
|
private_key = ${paths:base_key}/sign_rsa_key_1.pem
|
|
46
46
|
public_key = ${paths:base_key}/verify_rsa_key_1.pem
|
|
47
|
-
; key_type selects the algorithm for openbadges-keygenerator: RSA (default) or
|
|
47
|
+
; key_type selects the algorithm for openbadges-keygenerator: RSA (default), ECC, or ED25519
|
|
48
48
|
key_type = RSA
|
|
49
49
|
;alignement =
|
|
50
50
|
;tags =
|
|
@@ -87,10 +87,22 @@ class ConfParser():
|
|
|
87
87
|
raise ValueError(
|
|
88
88
|
"Configuration file %s has an empty [paths] 'base' value" % self.config_file)
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
# A relative base is resolved against the directory that contains the
|
|
91
|
+
# config file (not the process CWD), so ${base}/... paths land where the
|
|
92
|
+
# operator expects no matter where the tool is launched from. The old
|
|
93
|
+
# check only matched a bare '.' and replaced the *whole* value with the
|
|
94
|
+
# config dir — silently dropping the suffix of './data' or '../shared'
|
|
95
|
+
# (and mis-handling a real '.hidden' dir), which could redirect private
|
|
96
|
+
# keys and logs to the wrong tree with no error.
|
|
97
|
+
if not os.path.isabs(base):
|
|
98
|
+
base_dir = os.path.dirname(self.config_file)
|
|
99
|
+
resolved = os.path.abspath(os.path.join(base_dir, base))
|
|
100
|
+
# Writing back through the parser runs the value through
|
|
101
|
+
# ExtendedInterpolation, which treats '$' specially: a config
|
|
102
|
+
# directory whose absolute path contains a literal '$' would raise a
|
|
103
|
+
# raw configparser error (or silently collapse '$$' to '$'). Escape
|
|
104
|
+
# '$' -> '$$' so the path round-trips to its original form on read.
|
|
105
|
+
self.parser['paths']['base'] = resolved.replace('$', '$$')
|
|
94
106
|
|
|
95
107
|
# ExtendedInterpolation resolves ${...} references lazily, only when a
|
|
96
108
|
# value is actually read — a bad reference anywhere in the file would
|
|
@@ -20,19 +20,41 @@
|
|
|
20
20
|
License along with this library.
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
+
import re
|
|
23
24
|
import uuid
|
|
24
25
|
from dataclasses import dataclass, field
|
|
25
26
|
from datetime import datetime, timezone
|
|
26
27
|
from typing import Any, List, Optional
|
|
27
28
|
|
|
29
|
+
_VC2_CONTEXT = "https://www.w3.org/ns/credentials/v2"
|
|
30
|
+
|
|
28
31
|
OB3_CONTEXT = [
|
|
29
|
-
|
|
32
|
+
_VC2_CONTEXT,
|
|
30
33
|
"https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json",
|
|
31
34
|
]
|
|
32
35
|
|
|
36
|
+
# The OB v3p0 context URI, optionally version-pinned (…/context-3.0.3.json),
|
|
37
|
+
# mirroring the JSON Schema's @context[1] pattern.
|
|
38
|
+
_OB_CONTEXT_RE = re.compile(
|
|
39
|
+
r'^https://purl\.imsglobal\.org/spec/ob/v3p0/context(-\d+\.\d+\.\d+)?\.json$')
|
|
40
|
+
|
|
33
41
|
_SUPPORTED_ALGORITHMS = {'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA'}
|
|
34
42
|
|
|
35
43
|
|
|
44
|
+
def _validate_context(ctx: Any) -> None:
|
|
45
|
+
"""Validate an OpenBadgeCredential ``@context`` per the schema: an array
|
|
46
|
+
whose first item is the VC 2.0 context and whose second is the OB v3p0
|
|
47
|
+
context; extra items may follow. Raises ValueError otherwise."""
|
|
48
|
+
if not isinstance(ctx, list) or len(ctx) < 2:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"@context must be an array with the VC 2.0 and OB v3p0 contexts")
|
|
51
|
+
if ctx[0] != _VC2_CONTEXT:
|
|
52
|
+
raise ValueError("@context[0] must be %r" % (_VC2_CONTEXT,))
|
|
53
|
+
if not (isinstance(ctx[1], str) and _OB_CONTEXT_RE.match(ctx[1])):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"@context[1] must be the OB v3p0 context URI, got %r" % (ctx[1],))
|
|
56
|
+
|
|
57
|
+
|
|
36
58
|
def _iso(dt: datetime) -> str:
|
|
37
59
|
"""Return a datetime as an ISO 8601 string with Z suffix."""
|
|
38
60
|
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -90,7 +112,9 @@ class OpenBadgeCredential:
|
|
|
90
112
|
"""An OpenBadges 3.0 credential (W3C Verifiable Credential)."""
|
|
91
113
|
|
|
92
114
|
issuer: Issuer
|
|
93
|
-
|
|
115
|
+
# 'mailto:email@example.com' or a DID; Optional because OB3 makes
|
|
116
|
+
# credentialSubject.id optional (identity may travel via 'identifier').
|
|
117
|
+
recipient_id: Optional[str]
|
|
94
118
|
achievement: Achievement
|
|
95
119
|
id: Optional[str] = None # auto-generated as 'urn:uuid:…' if absent
|
|
96
120
|
name: Optional[str] = None # defaults to achievement.name
|
|
@@ -124,11 +148,13 @@ class OpenBadgeCredential:
|
|
|
124
148
|
"issuer": self.issuer.to_dict(),
|
|
125
149
|
"validFrom": _iso(self.issuance_date),
|
|
126
150
|
"credentialSubject": {
|
|
127
|
-
"id": self.recipient_id,
|
|
128
151
|
"type": ["AchievementSubject"],
|
|
129
152
|
"achievement": self.achievement.to_dict(),
|
|
130
153
|
},
|
|
131
154
|
}
|
|
155
|
+
# credentialSubject.id is optional; emit it only when present.
|
|
156
|
+
if self.recipient_id is not None:
|
|
157
|
+
vc["credentialSubject"]["id"] = self.recipient_id
|
|
132
158
|
if self.expiration_date:
|
|
133
159
|
vc["validUntil"] = _iso(self.expiration_date)
|
|
134
160
|
if self.evidence_url:
|
|
@@ -140,16 +166,23 @@ class OpenBadgeCredential:
|
|
|
140
166
|
return vc
|
|
141
167
|
|
|
142
168
|
def to_jwt_payload(self) -> dict:
|
|
143
|
-
"""Return the JWT payload for a JWT-VC signed credential.
|
|
169
|
+
"""Return the JWT payload for a JWT-VC signed credential.
|
|
170
|
+
|
|
171
|
+
OB 3.0 §8.2.4.1 (native VC-JWT): the JWT payload **is** the
|
|
172
|
+
OpenBadgeCredential — its members sit at the top level of the payload,
|
|
173
|
+
not nested under a ``vc`` claim — with the registered claims alongside:
|
|
174
|
+
``iss`` (issuer id), ``sub`` (credentialSubject id), ``jti`` (credential
|
|
175
|
+
id) and ``nbf`` (validFrom). ``exp`` mirrors validUntil when present.
|
|
176
|
+
There is deliberately no ``iat`` claim (the spec maps issuance to nbf).
|
|
177
|
+
"""
|
|
144
178
|
# __post_init__ guarantees issuance_date is set.
|
|
145
179
|
assert self.issuance_date is not None
|
|
146
|
-
payload: dict =
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
180
|
+
payload: dict = dict(self.to_vc()) # credential at the payload top level
|
|
181
|
+
payload["iss"] = self.issuer.id
|
|
182
|
+
if self.recipient_id is not None:
|
|
183
|
+
payload["sub"] = self.recipient_id
|
|
184
|
+
payload["jti"] = self.id
|
|
185
|
+
payload["nbf"] = int(self.issuance_date.timestamp())
|
|
153
186
|
if self.expiration_date:
|
|
154
187
|
payload["exp"] = int(self.expiration_date.timestamp())
|
|
155
188
|
return payload
|
|
@@ -160,21 +193,33 @@ class OpenBadgeCredential:
|
|
|
160
193
|
def from_jwt_payload(cls, payload: dict) -> "OpenBadgeCredential":
|
|
161
194
|
"""Reconstruct an OpenBadgeCredential from a decoded JWT payload.
|
|
162
195
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
196
|
+
OB 3.0 native VC-JWT: the payload IS the credential (its members at the
|
|
197
|
+
top level), so ``payload`` is read directly as the credential body. It
|
|
198
|
+
is untrusted input, so its structure is validated explicitly: every
|
|
199
|
+
required object/field is checked and a clear ``ValueError`` is raised on
|
|
200
|
+
anything missing or malformed (the OB3 verifier wraps these as
|
|
201
|
+
``OB3VerificationError``). The registered claims (iss/sub/jti/nbf/exp)
|
|
202
|
+
coexist at the top level and are simply ignored by the field reads here.
|
|
167
203
|
"""
|
|
168
|
-
vc = _as_dict(payload
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
204
|
+
vc = _as_dict(payload, "credential")
|
|
205
|
+
|
|
206
|
+
_validate_context(vc.get("@context"))
|
|
207
|
+
|
|
208
|
+
# issuer may be a Profile object or a bare string IRI (both schema-valid).
|
|
209
|
+
issuer_raw = vc.get("issuer")
|
|
210
|
+
if isinstance(issuer_raw, str):
|
|
211
|
+
if not issuer_raw:
|
|
212
|
+
raise ValueError("field vc.issuer must not be empty")
|
|
213
|
+
issuer = Issuer(id=issuer_raw, name="")
|
|
214
|
+
else:
|
|
215
|
+
issuer_data = _as_dict(issuer_raw, "vc.issuer")
|
|
216
|
+
issuer = Issuer(
|
|
217
|
+
id=_require(issuer_data, "id", "vc.issuer"),
|
|
218
|
+
name=issuer_data.get("name", ""),
|
|
219
|
+
url=issuer_data.get("url"),
|
|
220
|
+
email=issuer_data.get("email"),
|
|
221
|
+
image_url=_as_dict_or_empty(issuer_data.get("image")).get("id"),
|
|
222
|
+
)
|
|
178
223
|
|
|
179
224
|
# credentialSubject may be a single object or a (non-empty) array.
|
|
180
225
|
subj_raw = vc.get("credentialSubject")
|
|
@@ -219,10 +264,23 @@ class OpenBadgeCredential:
|
|
|
219
264
|
else:
|
|
220
265
|
credential_status = []
|
|
221
266
|
|
|
267
|
+
# credentialSubject.id is optional (schema); identity may instead be
|
|
268
|
+
# conveyed via one or more 'identifier' objects. Reject only when BOTH
|
|
269
|
+
# are absent — a subject with no identity at all is non-conformant.
|
|
270
|
+
recipient_id = subj.get("id")
|
|
271
|
+
if recipient_id is not None and not isinstance(recipient_id, str):
|
|
272
|
+
raise ValueError("field vc.credentialSubject.id must be a string")
|
|
273
|
+
if not recipient_id: # None or empty string
|
|
274
|
+
identifiers = subj.get("identifier")
|
|
275
|
+
if not (isinstance(identifiers, list) and identifiers):
|
|
276
|
+
raise ValueError(
|
|
277
|
+
"vc.credentialSubject must have an 'id' or an 'identifier'")
|
|
278
|
+
recipient_id = None
|
|
279
|
+
|
|
222
280
|
return cls(
|
|
223
281
|
id=_require(vc, "id", "vc"),
|
|
224
282
|
issuer=issuer,
|
|
225
|
-
recipient_id=
|
|
283
|
+
recipient_id=recipient_id,
|
|
226
284
|
achievement=achievement,
|
|
227
285
|
name=vc.get("name"),
|
|
228
286
|
issuance_date=issuance_date,
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
License along with this library.
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
+
import json
|
|
24
|
+
|
|
23
25
|
from typing import Any
|
|
24
26
|
|
|
25
27
|
import jwt
|
|
@@ -55,39 +57,69 @@ class OB3Signer:
|
|
|
55
57
|
# ── core signing ───────────────────────────────────────────────────────────
|
|
56
58
|
|
|
57
59
|
def sign(self, credential: OpenBadgeCredential) -> str:
|
|
58
|
-
"""Sign a credential and return a compact JWT-VC string.
|
|
60
|
+
"""Sign a credential and return a compact JWT-VC string.
|
|
61
|
+
|
|
62
|
+
OB 3.0 §8.2.3 requires the JOSE header to convey the verification key
|
|
63
|
+
via ``kid`` or ``jwk``; this embeds the issuer's public key as a ``jwk``
|
|
64
|
+
(public parameters only — never the private ``d``).
|
|
65
|
+
"""
|
|
59
66
|
payload = credential.to_jwt_payload()
|
|
67
|
+
headers = {"jwk": self._public_jwk()}
|
|
60
68
|
try:
|
|
61
|
-
return jwt.encode(payload, self.privkey_pem, algorithm=self.algorithm
|
|
69
|
+
return jwt.encode(payload, self.privkey_pem, algorithm=self.algorithm,
|
|
70
|
+
headers=headers)
|
|
62
71
|
except jwt.exceptions.PyJWTError as exc:
|
|
63
72
|
raise ErrorSigningFile(
|
|
64
73
|
"Could not sign credential with algorithm %s: %s" % (self.algorithm, exc)) from exc
|
|
65
74
|
|
|
75
|
+
def _public_jwk(self) -> dict:
|
|
76
|
+
"""Return the public JWK for the JOSE header, derived from the signing
|
|
77
|
+
key. Loaded via ``cryptography`` (which reads the RSA/EC/Ed25519 PEMs
|
|
78
|
+
this library produces) and serialised with PyJWT's algorithm; only
|
|
79
|
+
public parameters are included."""
|
|
80
|
+
from cryptography.hazmat.primitives import serialization as _ser
|
|
81
|
+
from jwt.algorithms import RSAAlgorithm, ECAlgorithm, OKPAlgorithm
|
|
82
|
+
pem = self.privkey_pem.encode('utf-8') if isinstance(self.privkey_pem, str) \
|
|
83
|
+
else self.privkey_pem
|
|
84
|
+
try:
|
|
85
|
+
# public_key() is a broad union; we dispatch on self.algorithm, so
|
|
86
|
+
# the concrete type matches the chosen to_jwk. Treat as Any for mypy.
|
|
87
|
+
pub: Any = _ser.load_pem_private_key(pem, password=None).public_key()
|
|
88
|
+
if self.algorithm.startswith('RS'):
|
|
89
|
+
jwk_json = RSAAlgorithm.to_jwk(pub)
|
|
90
|
+
elif self.algorithm.startswith('ES'):
|
|
91
|
+
jwk_json = ECAlgorithm.to_jwk(pub)
|
|
92
|
+
else: # EdDSA / Ed25519
|
|
93
|
+
jwk_json = OKPAlgorithm.to_jwk(pub)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
raise ErrorSigningFile("Could not derive the public JWK: %s" % exc) from exc
|
|
96
|
+
return json.loads(jwk_json)
|
|
97
|
+
|
|
66
98
|
# ── image baking ───────────────────────────────────────────────────────────
|
|
67
99
|
|
|
68
100
|
def sign_into_svg(self, credential: OpenBadgeCredential, svg_bytes: bytes) -> bytes:
|
|
69
101
|
"""Embed a signed credential into an SVG badge image.
|
|
70
102
|
|
|
71
|
-
The JWT-VC is stored in
|
|
72
|
-
element
|
|
73
|
-
viewers can extract the token regardless of version.
|
|
103
|
+
The JWT-VC is stored in the OB 3.0 ``<openbadges:credential verify="…"/>``
|
|
104
|
+
element (namespace ``https://purl.imsglobal.org/ob/v3p0``).
|
|
74
105
|
"""
|
|
75
106
|
token = self.sign(credential)
|
|
76
107
|
try:
|
|
77
108
|
return baking.bake_svg(
|
|
78
109
|
svg_bytes, token,
|
|
79
|
-
comment=' Signed with OpenBadgesLib %s (OB 3.0 JWT-VC) ' % __version__
|
|
110
|
+
comment=' Signed with OpenBadgesLib %s (OB 3.0 JWT-VC) ' % __version__,
|
|
111
|
+
element=baking.SVG_ELEMENT_OB3, namespace=baking.SVG_NS_OB3)
|
|
80
112
|
except Exception as exc:
|
|
81
|
-
raise ErrorSigningFile('Unable to bake SVG
|
|
113
|
+
raise ErrorSigningFile('Unable to bake SVG credential: %s' % exc) from exc
|
|
82
114
|
|
|
83
115
|
def sign_into_png(self, credential: OpenBadgeCredential, png_bytes: bytes) -> bytes:
|
|
84
116
|
"""Embed a signed credential into a PNG badge image.
|
|
85
117
|
|
|
86
|
-
The JWT-VC is stored in an ``iTXt`` chunk with keyword
|
|
87
|
-
|
|
118
|
+
The JWT-VC is stored in an ``iTXt`` chunk with the OB 3.0 keyword
|
|
119
|
+
``openbadgecredential``.
|
|
88
120
|
"""
|
|
89
121
|
token = self.sign(credential)
|
|
90
122
|
try:
|
|
91
|
-
return baking.bake_png(png_bytes, token)
|
|
123
|
+
return baking.bake_png(png_bytes, token, keyword=baking.ITXT_KEYWORD_OB3)
|
|
92
124
|
except Exception as exc:
|
|
93
|
-
raise ErrorSigningFile('Unable to bake PNG
|
|
125
|
+
raise ErrorSigningFile('Unable to bake PNG credential: %s' % exc) from exc
|
|
@@ -88,19 +88,47 @@ def _check_entry(entry: dict, download: Callable[[str], bytes]) -> None:
|
|
|
88
88
|
|
|
89
89
|
try:
|
|
90
90
|
subject = _status_list_subject(raw)
|
|
91
|
+
# Bitstring Status List v1.0 allows statusSize > 1 (multi-bit entries).
|
|
92
|
+
# _bit_set below assumes exactly one bit per entry, so honouring a
|
|
93
|
+
# larger statusSize would read the wrong bits and could misreport a
|
|
94
|
+
# revoked credential as valid. Fail closed on an unsupported size rather
|
|
95
|
+
# than silently returning the wrong verdict. Absent/1 keeps 1-bit logic.
|
|
96
|
+
status_size = subject.get("statusSize", 1)
|
|
97
|
+
try:
|
|
98
|
+
status_size = int(status_size)
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
raise OB3VerificationError("invalid statusSize: %r" % (status_size,))
|
|
101
|
+
if status_size != 1:
|
|
102
|
+
raise OB3VerificationError(
|
|
103
|
+
"unsupported statusSize %d: only single-bit status entries are "
|
|
104
|
+
"supported" % status_size)
|
|
91
105
|
encoded = subject.get("encodedList")
|
|
92
106
|
if not isinstance(encoded, str) or not encoded:
|
|
93
107
|
raise OB3VerificationError("status list credential has no encodedList")
|
|
94
108
|
bitstring = _decode_encoded_list(encoded)
|
|
109
|
+
list_purposes = set(_as_list(subject.get("statusPurpose")))
|
|
95
110
|
except OB3VerificationError:
|
|
96
111
|
raise
|
|
97
112
|
except Exception as exc:
|
|
98
113
|
raise OB3VerificationError(
|
|
99
114
|
"malformed status list %s: %s" % (list_url, exc)) from exc
|
|
100
115
|
|
|
101
|
-
|
|
116
|
+
# The entry's statusPurpose MUST be one the fetched status list declares,
|
|
117
|
+
# otherwise the entry points at the wrong list (fail closed).
|
|
118
|
+
if purpose not in list_purposes:
|
|
102
119
|
raise OB3VerificationError(
|
|
103
|
-
"
|
|
120
|
+
"credentialStatus statusPurpose %r is not declared by the status list "
|
|
121
|
+
"%s (declares %r)" % (purpose, list_url, subject.get("statusPurpose")))
|
|
122
|
+
|
|
123
|
+
if not _bit_set(bitstring, index):
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# A set bit only invalidates the credential for revocation/suspension.
|
|
127
|
+
# Any other purpose (e.g. 'message') is informational and MUST NOT fail
|
|
128
|
+
# verification — statusPurpose decides the meaning of the bit.
|
|
129
|
+
if purpose in ("revocation", "suspension"):
|
|
130
|
+
raise OB3VerificationError(
|
|
131
|
+
"Credential status '%s' is set at index %d in %s"
|
|
104
132
|
% (purpose, index, list_url))
|
|
105
133
|
|
|
106
134
|
|