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.
Files changed (75) hide show
  1. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/Changelog.txt +76 -0
  2. {openbadgeslib-1.2.0/openbadgeslib.egg-info → openbadgeslib-2.0.0}/PKG-INFO +3 -2
  3. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/README.md +2 -1
  4. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/baking.py +37 -24
  5. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/config.ini.example +1 -1
  6. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/confparser.py +16 -4
  7. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/credential.py +84 -26
  8. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/signer.py +43 -11
  9. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/status.py +30 -2
  10. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/verifier.py +68 -27
  11. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_verifier.py +33 -7
  12. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/util.py +98 -11
  13. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0/openbadgeslib.egg-info}/PKG-INFO +3 -2
  14. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_cli_json.py +86 -1
  15. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_confparser.py +50 -0
  16. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_credential.py +13 -7
  17. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_did.py +69 -0
  18. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_signer.py +27 -8
  19. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_status.py +46 -11
  20. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_ob3_verifier.py +98 -24
  21. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_util.py +76 -0
  22. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/LICENSE.txt +0 -0
  23. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/MANIFEST.in +0 -0
  24. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/docs/README.md +0 -0
  25. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/__init__.py +0 -0
  26. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/__init__.py +0 -0
  27. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/exceptions.py +0 -0
  28. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/_jws/utils.py +0 -0
  29. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/badge.py +0 -0
  30. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/errors.py +0 -0
  31. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/keys.py +0 -0
  32. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/logs.py +0 -0
  33. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/mail.py +0 -0
  34. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/__init__.py +0 -0
  35. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/badge.py +0 -0
  36. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/signer.py +0 -0
  37. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob2/verifier.py +0 -0
  38. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/__init__.py +0 -0
  39. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/ob3/did.py +0 -0
  40. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_init.py +0 -0
  41. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  42. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_publish.py +0 -0
  43. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/openbadges_signer.py +0 -0
  44. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/py.typed +0 -0
  45. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/signer.py +0 -0
  46. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib/verifier.py +0 -0
  47. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  48. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  49. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  50. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/requires.txt +0 -0
  51. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  52. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/pyproject.toml +0 -0
  53. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/setup.cfg +0 -0
  54. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/config1.ini +0 -0
  55. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/conftest.py +0 -0
  56. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/images/sample1.png +0 -0
  57. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/images/sample1.svg +0 -0
  58. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/images/userimage01.svg +0 -0
  59. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  60. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  61. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/runtests.sh +0 -0
  62. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_badge_io.py +0 -0
  63. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_cli_smoke.py +0 -0
  64. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_docs.py +0 -0
  65. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_eddsa.py +0 -0
  66. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_jws.py +0 -0
  67. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_key_operation.py +0 -0
  68. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_sign_ecc.pem +0 -0
  69. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_sign_rsa.pem +0 -0
  70. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_signer_operation.py +0 -0
  71. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_verify_ecc.pem +0 -0
  72. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_verify_operation.py +0 -0
  73. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/test_verify_rsa.pem +0 -0
  74. {openbadgeslib-1.2.0 → openbadgeslib-2.0.0}/tests/withoutxmlheader.svg +0 -0
  75. {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: 1.2.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
@@ -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) and ECC NIST P-256 (ES256) key support
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) and ECC NIST P-256 (ES256) key support
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
- 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]
@@ -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 ECC
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
- if base[0] == '.':
91
- abs_path = os.path.dirname(self.config_file)
92
- full_path = os.path.abspath(abs_path)
93
- self.parser['paths']['base'] = full_path
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
- "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
@@ -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
- 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:
102
119
  raise OB3VerificationError(
103
- "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"
104
132
  % (purpose, index, list_url))
105
133
 
106
134