openbadgeslib 1.3.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.3.0 → openbadgeslib-2.0.0}/Changelog.txt +30 -0
- {openbadgeslib-1.3.0/openbadgeslib.egg-info → openbadgeslib-2.0.0}/PKG-INFO +1 -1
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/baking.py +37 -24
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/credential.py +84 -26
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/signer.py +43 -11
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/status.py +16 -2
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/verifier.py +40 -25
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/util.py +1 -1
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0/openbadgeslib.egg-info}/PKG-INFO +1 -1
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_credential.py +13 -7
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_signer.py +27 -8
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_status.py +29 -11
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_verifier.py +98 -24
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/LICENSE.txt +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/MANIFEST.in +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/README.md +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/docs/README.md +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/badge.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/config.ini.example +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/confparser.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/badge.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/verifier.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_publish.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_signer.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_verifier.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/signer.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/verifier.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/pyproject.toml +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/setup.cfg +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/config1.ini +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/conftest.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/runtests.sh +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_badge_io.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_cli_json.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_confparser.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_docs.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_jws.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_signer_operation.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_util.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_verify_operation.py +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,36 @@ 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
|
+
|
|
7
37
|
* v1.3.0 - 2026-07-01
|
|
8
38
|
|
|
9
39
|
- SECURITY: download_file() now blocks server-side request forgery. The URLs
|
|
@@ -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]
|
|
@@ -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
|
|
@@ -106,15 +106,29 @@ def _check_entry(entry: dict, download: Callable[[str], bytes]) -> None:
|
|
|
106
106
|
if not isinstance(encoded, str) or not encoded:
|
|
107
107
|
raise OB3VerificationError("status list credential has no encodedList")
|
|
108
108
|
bitstring = _decode_encoded_list(encoded)
|
|
109
|
+
list_purposes = set(_as_list(subject.get("statusPurpose")))
|
|
109
110
|
except OB3VerificationError:
|
|
110
111
|
raise
|
|
111
112
|
except Exception as exc:
|
|
112
113
|
raise OB3VerificationError(
|
|
113
114
|
"malformed status list %s: %s" % (list_url, exc)) from exc
|
|
114
115
|
|
|
115
|
-
|
|
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:
|
|
116
119
|
raise OB3VerificationError(
|
|
117
|
-
"
|
|
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"
|
|
118
132
|
% (purpose, index, list_url))
|
|
119
133
|
|
|
120
134
|
|
|
@@ -51,6 +51,8 @@ def _claim_object_id(value: Any) -> Any:
|
|
|
51
51
|
fail the comparison rather than raising a raw AttributeError on, e.g.,
|
|
52
52
|
``[ {…} ].get("id")``.
|
|
53
53
|
"""
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return value # issuer/subject given as a bare IRI
|
|
54
56
|
if isinstance(value, list):
|
|
55
57
|
value = value[0] if value else None
|
|
56
58
|
if isinstance(value, dict):
|
|
@@ -222,27 +224,34 @@ class OB3Verifier:
|
|
|
222
224
|
|
|
223
225
|
@staticmethod
|
|
224
226
|
def _build_credential(payload: dict) -> OpenBadgeCredential:
|
|
225
|
-
"""Validate the
|
|
226
|
-
reconstructed credential.
|
|
227
|
-
if "vc" not in payload:
|
|
228
|
-
raise OB3VerificationError(
|
|
229
|
-
"JWT payload does not contain a 'vc' claim — "
|
|
230
|
-
"this may be an OB 2.0 JWS token, not an OB 3.0 JWT-VC"
|
|
231
|
-
)
|
|
227
|
+
"""Validate the credential structure and registered claims, returning
|
|
228
|
+
the reconstructed credential.
|
|
232
229
|
|
|
233
|
-
|
|
234
|
-
|
|
230
|
+
OB 3.0 native VC-JWT (§8.2.4.1): the JWT payload IS the credential, so
|
|
231
|
+
it is read directly — there is no ``vc`` claim wrapper.
|
|
232
|
+
"""
|
|
233
|
+
if not isinstance(payload, dict):
|
|
235
234
|
raise OB3VerificationError(
|
|
236
|
-
"JWT
|
|
235
|
+
"JWT payload must be a JSON object, got %s" % type(payload).__name__)
|
|
236
|
+
vc = payload # native: the payload is the credential body
|
|
237
237
|
|
|
238
238
|
vc_types = vc.get("type", [])
|
|
239
239
|
if isinstance(vc_types, str):
|
|
240
240
|
vc_types = [vc_types]
|
|
241
241
|
elif not isinstance(vc_types, list):
|
|
242
242
|
vc_types = []
|
|
243
|
-
|
|
243
|
+
# OpenBadgeCredential and AchievementCredential are aliases in the OB v3
|
|
244
|
+
# context; accept either. VerifiableCredential must also be present.
|
|
245
|
+
if not ({"OpenBadgeCredential", "AchievementCredential"} & set(vc_types)):
|
|
244
246
|
raise OB3VerificationError(
|
|
245
|
-
"JWT
|
|
247
|
+
"JWT payload is not an OpenBadgeCredential/AchievementCredential "
|
|
248
|
+
"(type=%r) — this may be an OB 2.0 JWS token, not an OB 3.0 JWT-VC"
|
|
249
|
+
% (vc_types,)
|
|
250
|
+
)
|
|
251
|
+
if "VerifiableCredential" not in vc_types:
|
|
252
|
+
raise OB3VerificationError(
|
|
253
|
+
"JWT payload type must include 'VerifiableCredential' (type=%r)"
|
|
254
|
+
% (vc_types,)
|
|
246
255
|
)
|
|
247
256
|
|
|
248
257
|
try:
|
|
@@ -250,17 +259,23 @@ class OB3Verifier:
|
|
|
250
259
|
except (KeyError, ValueError, TypeError) as exc:
|
|
251
260
|
raise OB3VerificationError(f"Malformed credential payload: {exc}") from exc
|
|
252
261
|
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
262
|
+
# OB3 §8.2.6.1: iss and nbf are REQUIRED registered claims, and iss MUST
|
|
263
|
+
# equal the credential issuer id. sub is required (and must match) when
|
|
264
|
+
# the subject carries an id. Enforcing presence — not only cross-checking
|
|
265
|
+
# when present — stops a token that omits these claims from verifying.
|
|
256
266
|
iss = payload.get("iss")
|
|
257
|
-
if iss is
|
|
258
|
-
raise OB3VerificationError(
|
|
259
|
-
|
|
267
|
+
if iss is None:
|
|
268
|
+
raise OB3VerificationError("JWT payload is missing the required 'iss' claim")
|
|
269
|
+
if iss != _claim_object_id(vc.get("issuer")):
|
|
270
|
+
raise OB3VerificationError("JWT 'iss' does not match the credential issuer")
|
|
271
|
+
if payload.get("nbf") is None:
|
|
272
|
+
raise OB3VerificationError("JWT payload is missing the required 'nbf' claim")
|
|
260
273
|
sub = payload.get("sub")
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
274
|
+
subject_id = _claim_object_id(vc.get("credentialSubject"))
|
|
275
|
+
if subject_id is not None and sub is None:
|
|
276
|
+
raise OB3VerificationError("JWT payload is missing the required 'sub' claim")
|
|
277
|
+
if sub is not None and sub != subject_id:
|
|
278
|
+
raise OB3VerificationError("JWT 'sub' does not match the credentialSubject id")
|
|
264
279
|
|
|
265
280
|
return credential
|
|
266
281
|
|
|
@@ -270,11 +285,11 @@ class OB3Verifier:
|
|
|
270
285
|
def extract_token_from_svg(svg_bytes: bytes) -> str:
|
|
271
286
|
"""Extract the JWT-VC token embedded in a baked SVG badge."""
|
|
272
287
|
try:
|
|
273
|
-
token = baking.extract_svg(svg_bytes)
|
|
288
|
+
token = baking.extract_svg(svg_bytes, element=baking.SVG_ELEMENT_OB3)
|
|
274
289
|
except Exception as exc:
|
|
275
290
|
raise ErrorParsingFile(f"Could not parse SVG: {exc}") from exc
|
|
276
291
|
if token is None:
|
|
277
|
-
raise OB3VerificationError("No openbadges:
|
|
292
|
+
raise OB3VerificationError("No openbadges:credential element found in SVG")
|
|
278
293
|
return token
|
|
279
294
|
|
|
280
295
|
@staticmethod
|
|
@@ -287,11 +302,11 @@ class OB3Verifier:
|
|
|
287
302
|
a non-empty language tag or compressed text — are also recovered.
|
|
288
303
|
"""
|
|
289
304
|
try:
|
|
290
|
-
token = baking.extract_png(png_bytes)
|
|
305
|
+
token = baking.extract_png(png_bytes, keyword=baking.ITXT_KEYWORD_OB3)
|
|
291
306
|
except baking.DecompressionLimitExceeded as exc:
|
|
292
307
|
raise OB3VerificationError(str(exc)) from exc
|
|
293
308
|
except Exception as exc:
|
|
294
309
|
raise ErrorParsingFile(f"Could not parse PNG: {exc}") from exc
|
|
295
310
|
if token is None:
|
|
296
|
-
raise OB3VerificationError("No
|
|
311
|
+
raise OB3VerificationError("No openbadgecredential iTXt chunk found in PNG")
|
|
297
312
|
return token
|