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.
Files changed (75) hide show
  1. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/Changelog.txt +30 -0
  2. {openbadgeslib-1.3.0/openbadgeslib.egg-info → openbadgeslib-2.0.0}/PKG-INFO +1 -1
  3. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/baking.py +37 -24
  4. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/credential.py +84 -26
  5. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/signer.py +43 -11
  6. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/status.py +16 -2
  7. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/verifier.py +40 -25
  8. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/util.py +1 -1
  9. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0/openbadgeslib.egg-info}/PKG-INFO +1 -1
  10. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_credential.py +13 -7
  11. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_signer.py +27 -8
  12. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_status.py +29 -11
  13. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_verifier.py +98 -24
  14. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/LICENSE.txt +0 -0
  15. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/MANIFEST.in +0 -0
  16. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/README.md +0 -0
  17. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/docs/README.md +0 -0
  18. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/__init__.py +0 -0
  19. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/__init__.py +0 -0
  20. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/exceptions.py +0 -0
  21. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/utils.py +0 -0
  22. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/badge.py +0 -0
  23. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/config.ini.example +0 -0
  24. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/confparser.py +0 -0
  25. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/errors.py +0 -0
  26. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/keys.py +0 -0
  27. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/logs.py +0 -0
  28. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/mail.py +0 -0
  29. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/__init__.py +0 -0
  30. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/badge.py +0 -0
  31. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/signer.py +0 -0
  32. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/verifier.py +0 -0
  33. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/__init__.py +0 -0
  34. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/did.py +0 -0
  35. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_init.py +0 -0
  36. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  37. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_publish.py +0 -0
  38. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_signer.py +0 -0
  39. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_verifier.py +0 -0
  40. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/py.typed +0 -0
  41. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/signer.py +0 -0
  42. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib/verifier.py +0 -0
  43. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  44. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  45. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  46. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/requires.txt +0 -0
  47. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  48. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/pyproject.toml +0 -0
  49. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/setup.cfg +0 -0
  50. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/config1.ini +0 -0
  51. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/conftest.py +0 -0
  52. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/images/sample1.png +0 -0
  53. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/images/sample1.svg +0 -0
  54. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/images/userimage01.svg +0 -0
  55. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  56. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  57. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/runtests.sh +0 -0
  58. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_badge_io.py +0 -0
  59. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_cli_json.py +0 -0
  60. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_cli_smoke.py +0 -0
  61. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_confparser.py +0 -0
  62. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_docs.py +0 -0
  63. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_eddsa.py +0 -0
  64. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_jws.py +0 -0
  65. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_key_operation.py +0 -0
  66. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_ob3_did.py +0 -0
  67. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_sign_ecc.pem +0 -0
  68. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_sign_rsa.pem +0 -0
  69. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_signer_operation.py +0 -0
  70. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_util.py +0 -0
  71. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_verify_ecc.pem +0 -0
  72. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_verify_operation.py +0 -0
  73. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/test_verify_rsa.pem +0 -0
  74. {openbadgeslib-1.3.0 → openbadgeslib-2.0.0}/tests/withoutxmlheader.svg +0 -0
  75. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 1.3.0
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
@@ -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
- keyword, sep, rest = data.partition(b'\x00')
52
- if sep != b'\x00' or keyword != ITXT_KEYWORD or len(rest) < 2:
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) -> bytes:
70
- """Return *image_bytes* with an ``<openbadges:assertion verify=token>``
71
- element (and an optional XML comment) appended to the root ``<svg>``."""
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(SVG_ELEMENT)
76
- node.attributes['xmlns:openbadges'] = 'http://openbadges.org'
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 OpenBadges assertion."""
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(SVG_ELEMENT))
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 assertion node.
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(SVG_ELEMENT)
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) -> bytes:
128
- """Return *image_bytes* with the token stored in an ``openbadges`` iTXt
129
- chunk (and an optional ``tEXt`` comment chunk) inserted before IEND."""
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 = ITXT_KEYWORD + pack('BBBBB', 0, 0, 0, 0, 0) + token.encode('utf-8')
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 an OpenBadges iTXt chunk."""
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) -> Optional[str]:
148
- """Return the embedded token string, or None if there is no openbadges
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
- "https://www.w3.org/ns/credentials/v2",
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
- recipient_id: str # 'mailto:email@example.com' or a DID
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
- "iss": self.issuer.id,
148
- "sub": self.recipient_id,
149
- "jti": self.id,
150
- "iat": int(self.issuance_date.timestamp()),
151
- "vc": self.to_vc(),
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
- The ``vc`` claim is untrusted input, so its structure is validated
164
- explicitly: every required object/field is checked and a clear
165
- ``ValueError`` is raised on anything missing or malformed (the OB3
166
- verifier wraps these as ``OB3VerificationError``).
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.get("vc"), "vc")
169
-
170
- issuer_data = _as_dict(vc.get("issuer"), "vc.issuer")
171
- issuer = Issuer(
172
- id=_require(issuer_data, "id", "vc.issuer"),
173
- name=issuer_data.get("name", ""),
174
- url=issuer_data.get("url"),
175
- email=issuer_data.get("email"),
176
- image_url=_as_dict_or_empty(issuer_data.get("image")).get("id"),
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=_require(subj, "id", "vc.credentialSubject"),
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 an ``<openbadges:assertion verify="…"/>``
72
- element, matching the OB 2.0 baking format so that existing badge
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 assertion: %s' % exc) from exc
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 ``openbadges``,
87
- matching the OB 2.0 baking format.
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 assertion: %s' % exc) from exc
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
- if _bit_set(bitstring, index):
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
- "Credential status is set (%s) at index %d in %s"
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 vc structure and registered claims, returning 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
- vc = payload["vc"]
234
- if not isinstance(vc, dict):
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 'vc' claim must be an object, got %s" % type(vc).__name__)
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
- if "OpenBadgeCredential" not in vc_types:
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 'vc' claim is not an OpenBadgeCredential (type=%r)" % (vc_types,)
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
- # Cross-check the JWT registered claims against the vc body (when the
254
- # token carries them) so a verified signature cannot pair an iss/sub
255
- # with a mismatched credential issuer/subject.
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 not None and iss != _claim_object_id(vc.get("issuer")):
258
- raise OB3VerificationError(
259
- "JWT 'iss' does not match the credential issuer")
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
- if sub is not None and sub != _claim_object_id(vc.get("credentialSubject")):
262
- raise OB3VerificationError(
263
- "JWT 'sub' does not match the credentialSubject id")
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:assertion element found in SVG")
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 openbadges iTXt chunk found in PNG")
311
+ raise OB3VerificationError("No openbadgecredential iTXt chunk found in PNG")
297
312
  return token
@@ -21,7 +21,7 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- __version__ = '1.3.0'
24
+ __version__ = '2.0.0'
25
25
 
26
26
  import hashlib
27
27
  import ipaddress
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 1.3.0
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