openbadgeslib 1.2.0__tar.gz → 1.3.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-1.3.0}/Changelog.txt +46 -0
  2. {openbadgeslib-1.2.0/openbadgeslib.egg-info → openbadgeslib-1.3.0}/PKG-INFO +3 -2
  3. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/README.md +2 -1
  4. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/config.ini.example +1 -1
  5. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/confparser.py +16 -4
  6. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/status.py +14 -0
  7. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/verifier.py +28 -2
  8. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_verifier.py +33 -7
  9. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/util.py +98 -11
  10. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0/openbadgeslib.egg-info}/PKG-INFO +3 -2
  11. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_cli_json.py +86 -1
  12. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_confparser.py +50 -0
  13. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_did.py +69 -0
  14. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_status.py +17 -0
  15. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_util.py +76 -0
  16. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/LICENSE.txt +0 -0
  17. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/MANIFEST.in +0 -0
  18. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/docs/README.md +0 -0
  19. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/__init__.py +0 -0
  20. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/_jws/__init__.py +0 -0
  21. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/_jws/exceptions.py +0 -0
  22. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/_jws/utils.py +0 -0
  23. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/badge.py +0 -0
  24. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/baking.py +0 -0
  25. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/errors.py +0 -0
  26. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/keys.py +0 -0
  27. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/logs.py +0 -0
  28. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/mail.py +0 -0
  29. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob2/__init__.py +0 -0
  30. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob2/badge.py +0 -0
  31. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob2/signer.py +0 -0
  32. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob2/verifier.py +0 -0
  33. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/__init__.py +0 -0
  34. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/credential.py +0 -0
  35. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/did.py +0 -0
  36. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/signer.py +0 -0
  37. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_init.py +0 -0
  38. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  39. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_publish.py +0 -0
  40. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_signer.py +0 -0
  41. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/py.typed +0 -0
  42. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/signer.py +0 -0
  43. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/verifier.py +0 -0
  44. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  45. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  46. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  47. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/requires.txt +0 -0
  48. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  49. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/pyproject.toml +0 -0
  50. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/setup.cfg +0 -0
  51. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/config1.ini +0 -0
  52. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/conftest.py +0 -0
  53. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/images/sample1.png +0 -0
  54. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/images/sample1.svg +0 -0
  55. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/images/userimage01.svg +0 -0
  56. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  57. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  58. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/runtests.sh +0 -0
  59. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_badge_io.py +0 -0
  60. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_cli_smoke.py +0 -0
  61. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_docs.py +0 -0
  62. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_eddsa.py +0 -0
  63. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_jws.py +0 -0
  64. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_key_operation.py +0 -0
  65. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_credential.py +0 -0
  66. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_signer.py +0 -0
  67. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_verifier.py +0 -0
  68. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_sign_ecc.pem +0 -0
  69. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_sign_rsa.pem +0 -0
  70. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_signer_operation.py +0 -0
  71. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_verify_ecc.pem +0 -0
  72. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_verify_operation.py +0 -0
  73. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_verify_rsa.pem +0 -0
  74. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/withoutxmlheader.svg +0 -0
  75. {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,52 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v1.3.0 - 2026-07-01
8
+
9
+ - SECURITY: download_file() now blocks server-side request forgery. The URLs
10
+ it fetches are attacker-influenced (an OB2 badge/issuer/revocationList URL,
11
+ an OB3 did:web host, an OB3 credentialStatus list), so it now resolves the
12
+ destination host and refuses any private, loopback, link-local, reserved,
13
+ multicast, unspecified or carrier-grade-NAT (100.64/10) address; the check
14
+ is re-applied to redirect targets. Previously a crafted badge could steer a
15
+ verifier into GETting cloud-metadata endpoints or internal hosts. An
16
+ allow_private=True opt-out is available for private deployments.
17
+
18
+ - SECURITY: OB3Verifier.for_issuer_did() now binds the credential to the
19
+ anchored DID — verify() requires the credential's issuer id to equal the
20
+ DID whose key was resolved. did:web is not self-certifying, so without this
21
+ a credential signed by the resolved key could claim a different (trusted)
22
+ issuer and be accepted. Verifiers built directly from a public key are
23
+ unchanged (the caller vouches for the key).
24
+
25
+ - SECURITY: openbadges-verifier --json exit status now reflects issuer trust,
26
+ not just signature validity: 0 = valid AND issuer-trusted, 2 = valid
27
+ signature but untrusted issuer (an OB2 badge-embedded key or a self-asserted
28
+ did:key), 1 = failure. In 1.2.0 a valid-but-untrusted badge exited 0, so CI
29
+ gating on the exit code accepted signatures that do not prove issuer
30
+ identity. NOTE: this changes the exit code for the valid-but-untrusted case
31
+ from 0 to 2; the JSON body (valid/trusted/reason) is unchanged.
32
+
33
+ - SECURITY: OB3 --resolve-did on a self-asserted did:key is now reported
34
+ trusted:false (with a warning), mirroring OB2's badge-embedded-key case;
35
+ only an operator-supplied key or a DNS/TLS-anchored did:web is trusted.
36
+
37
+ - fix: an OB3 Bitstring Status List with statusSize > 1 (multi-bit entries)
38
+ now fails closed instead of reading the wrong bit, which could have
39
+ reported a revoked credential as valid.
40
+
41
+ - fix: confparser now resolves a relative [paths] base against the directory
42
+ containing the config file (not the process CWD). Previously only a bare
43
+ '.' was handled and its value replaced wholesale, silently dropping the
44
+ suffix of './data' or '../shared' and misplacing key/log/image trees. A '$'
45
+ in the config directory path is handled correctly.
46
+
47
+ - docs: Ed25519 (EdDSA) support is now documented across README and the wiki
48
+ (key types, algorithms, keygenerator key_type, config example); the --json
49
+ exit-status scheme is documented; and stale/inaccurate crypto-library and
50
+ dependency notes were corrected.
51
+
52
+
7
53
  * v1.2.0 - 2026-07-01
8
54
 
9
55
  - 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: 1.3.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
@@ -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
@@ -88,6 +88,20 @@ 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")
@@ -74,8 +74,11 @@ class OB3Verifier:
74
74
  ecdsa key object).
75
75
  """
76
76
 
77
- def __init__(self, pubkey_pem: Any) -> None:
77
+ def __init__(self, pubkey_pem: Any, issuer_did: Optional[str] = None) -> None:
78
78
  self.pubkey_pem = key_to_pem(pubkey_pem)
79
+ # When the key was obtained by resolving a DID, remember that DID so
80
+ # verify() can bind the credential's stated issuer to it (see verify()).
81
+ self._anchored_did = issuer_did
79
82
  # Pin the accepted algorithms to this key's type so the token header
80
83
  # cannot dictate the algorithm (alg:none / HMAC downgrade / cross-type
81
84
  # confusion are all rejected up front rather than trusted).
@@ -94,9 +97,16 @@ class OB3Verifier:
94
97
  OB3VerificationError for an unsupported method or a resolution failure.
95
98
  Imported lazily so DID resolution (and its network path) is only pulled
96
99
  in when a caller actually anchors trust on a DID.
100
+
101
+ The resulting verifier is anchored to ``did``: verify() additionally
102
+ requires the credential's own issuer id to equal ``did``, so a token
103
+ signed by the resolved key but *claiming* a different issuer is
104
+ rejected. For did:key this is implied by the key being the identifier;
105
+ for did:web (not self-certifying) it is the check that stops an issuer
106
+ from being spoofed once its document has been fetched.
97
107
  """
98
108
  from .did import resolve_did
99
- return cls(pubkey_pem=resolve_did(did, download=download))
109
+ return cls(pubkey_pem=resolve_did(did, download=download), issuer_did=did)
100
110
 
101
111
  # ── verification ───────────────────────────────────────────────────────────
102
112
 
@@ -116,6 +126,12 @@ class OB3Verifier:
116
126
  ``credentialSubject.id`` matches; otherwise the caller MUST compare
117
127
  ``credential.recipient_id`` itself.
118
128
 
129
+ Issuer binding: when this verifier was built via ``for_issuer_did``, the
130
+ credential's issuer id is additionally required to equal the anchored
131
+ DID, so a token signed by the resolved key but claiming a different
132
+ issuer is rejected. A verifier built directly from a public key performs
133
+ no issuer binding — the caller vouches for the key's owner.
134
+
119
135
  Pass ``check_status=True`` to also check the credential's
120
136
  ``credentialStatus`` (revocation) — this performs an HTTPS fetch of the
121
137
  referenced status list and fails closed if the credential is revoked or
@@ -125,6 +141,16 @@ class OB3Verifier:
125
141
  payload = self._decode_payload(token)
126
142
  credential = self._build_credential(payload)
127
143
 
144
+ # Bind the credential to the DID this verifier was anchored to (if any).
145
+ # for_issuer_did resolves a DID to a key; without this check verify()
146
+ # would accept a token signed by that key even when the credential
147
+ # claims a *different* issuer (a did:web is not self-certifying, so its
148
+ # fetched key is otherwise decoupled from the credential's issuer id).
149
+ if self._anchored_did is not None and credential.issuer.id != self._anchored_did:
150
+ raise OB3VerificationError(
151
+ "Credential issuer %r does not match the DID the verifier was "
152
+ "anchored to (%r)" % (credential.issuer.id, self._anchored_did))
153
+
128
154
  # The JWT 'exp'/'iat' claims (checked above by PyJWT) are attacker-
129
155
  # supplied and can be decoupled from vc.validUntil/validFrom, which is
130
156
  # what downstream consumers actually read. Re-validate the vc-level
@@ -99,7 +99,9 @@ def build_parser() -> argparse.ArgumentParser:
99
99
  help='OpenBadges specification version: 2 (default, JWS) or 3 (JWT-VC).')
100
100
  parser.add_argument('--json', action='store_true',
101
101
  help='Emit a machine-readable JSON result instead of the human '
102
- 'output. Exits 0 when valid, non-zero otherwise.')
102
+ 'output. Exit status: 0 when the badge is valid AND the '
103
+ 'issuer is trusted; 2 when the signature is valid but the '
104
+ 'issuer is not anchored (untrusted); 1 on any failure.')
103
105
  parser.add_argument('-d', '--debug', action='store_true',
104
106
  help='Show debug messages at runtime.')
105
107
  parser.add_argument('-v', '--version', action='version',
@@ -110,14 +112,21 @@ def build_parser() -> argparse.ArgumentParser:
110
112
  def _finish(args: argparse.Namespace, result: Dict[str, Any]) -> None:
111
113
  """Emit the verification result and set the process exit status.
112
114
 
113
- In --json mode a single JSON object is printed and the process exits 0 when
114
- valid, non-zero otherwise. Without --json the human lines have already been
115
+ In --json mode a single JSON object is printed and the process exit status
116
+ reflects issuer trust, not merely signature validity: 0 when the badge is
117
+ valid AND trusted, 2 when the signature is valid but the issuer is not
118
+ anchored (an OB2 badge-embedded key or a self-asserted did:key), and 1 on
119
+ any failure. Collapsing 'valid but untrusted' into an exit-0 success would
120
+ let automation gate on a signature that only proves internal consistency,
121
+ not who issued the badge. Without --json the human lines have already been
115
122
  printed; the historical exit behaviour is preserved via result['_exit']
116
123
  (None means return without exiting, i.e. a normal exit 0)."""
117
124
  if args.json:
118
125
  payload = {k: v for k, v in result.items() if not k.startswith('_')}
119
126
  print(json.dumps(payload))
120
- sys.exit(0 if result.get('valid') else 1)
127
+ if not result.get('valid'):
128
+ sys.exit(1)
129
+ sys.exit(0 if result.get('trusted') else 2)
121
130
  code = result.get('_exit')
122
131
  if code is not None:
123
132
  sys.exit(code)
@@ -278,6 +287,12 @@ def _verify_ob3(args: argparse.Namespace) -> None:
278
287
  else:
279
288
  issuer_did = _issuer_did_from_token(token)
280
289
  result['issuer_did'] = issuer_did
290
+ # The DID is read from the untrusted token itself. A did:key IS the
291
+ # presenter's chosen key, so resolving it proves only internal
292
+ # consistency, not issuer identity — mark it untrusted, mirroring
293
+ # OB2's badge-embedded-key case. did:web is anchored on the issuer's
294
+ # DNS + TLS, so it stays trusted.
295
+ result['trusted'] = issuer_did.startswith('did:web:')
281
296
  if not args.json:
282
297
  print('[*] Resolving issuer DID %s' % issuer_did)
283
298
  verifier = OB3Verifier.for_issuer_did(issuer_did)
@@ -291,7 +306,6 @@ def _verify_ob3(args: argparse.Namespace) -> None:
291
306
  return
292
307
 
293
308
  result['valid'] = True
294
- result['reason'] = None
295
309
  result['_exit'] = None
296
310
  result['issuer'] = credential.issuer.name
297
311
  result['achievement'] = credential.achievement.name
@@ -310,8 +324,20 @@ def _verify_ob3(args: argparse.Namespace) -> None:
310
324
  if credential.evidence_url:
311
325
  print('[+] Evidence : %s' % credential.evidence_url)
312
326
 
313
- if not args.json:
314
- print('[+] OB3 signature is valid for the identity %s' % args.receptor)
327
+ if result['trusted']:
328
+ result['reason'] = None
329
+ if not args.json:
330
+ print('[+] OB3 signature is valid for the identity %s' % args.receptor)
331
+ else:
332
+ result['reason'] = ('signature is valid but verified against a key resolved '
333
+ 'from the credential\'s own did:key (self-asserted), not a '
334
+ 'trusted issuer key')
335
+ if not args.json:
336
+ print('[~] OB3 signature is internally consistent for %s, but it was '
337
+ 'verified against a key resolved from the credential\'s own '
338
+ 'did:key. This does NOT prove issuer identity. Supply --pubkey '
339
+ 'FILE / --local BADGE, or anchor a did:web issuer, to establish '
340
+ 'trust.' % args.receptor)
315
341
 
316
342
  _finish(args, result)
317
343
 
@@ -21,10 +21,12 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- __version__ = '1.2.0'
24
+ __version__ = '1.3.0'
25
25
 
26
26
  import hashlib
27
- from typing import Any, Optional, Union, overload
27
+ import ipaddress
28
+ import socket
29
+ from typing import Any, List, Optional, Union, overload
28
30
  from urllib import request
29
31
  from urllib.parse import urlparse
30
32
 
@@ -99,17 +101,88 @@ def hash_email(email: StrOrBytes, salt: StrOrBytes) -> bytes:
99
101
  return sha256_string(email + salt)
100
102
 
101
103
 
104
+ def _resolve_host(host: str, port: int) -> List[str]:
105
+ """Resolve *host* to a list of IP-address strings.
106
+
107
+ A thin wrapper over socket.getaddrinfo, kept as the single dependency seam
108
+ the SSRF host check uses so tests can patch resolution without the network.
109
+ """
110
+ infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
111
+ # sockaddr[0] is the address; getaddrinfo's sockaddr is typed as a tuple
112
+ # union (str | int), so coerce to str for the annotated return type.
113
+ return [str(info[4][0]) for info in infos]
114
+
115
+
116
+ # RFC 6598 carrier-grade NAT shared address space. Python's ipaddress does not
117
+ # flag it as private/reserved, but it is not globally routable and commonly
118
+ # fronts internal ISP/cloud infrastructure, so it is a valid SSRF target.
119
+ _CGNAT_V4 = ipaddress.ip_network('100.64.0.0/10')
120
+
121
+
122
+ def _ip_is_blocked(ip_str: str) -> bool:
123
+ """True if *ip_str* is a loopback/private/link-local/reserved/CGNAT address
124
+ a verifier must never be steered into fetching (an SSRF sink)."""
125
+ try:
126
+ ip = ipaddress.ip_address(ip_str)
127
+ except ValueError:
128
+ return True # unparseable address: fail closed
129
+ # An IPv4-mapped IPv6 address (::ffff:127.0.0.1) must be judged by its
130
+ # embedded IPv4 address, not the v6 wrapper, or the loopback slips through.
131
+ if ip.version == 6 and ip.ipv4_mapped is not None:
132
+ ip = ip.ipv4_mapped
133
+ if ip.version == 4 and ip in _CGNAT_V4:
134
+ return True
135
+ return (ip.is_private or ip.is_loopback or ip.is_link_local
136
+ or ip.is_reserved or ip.is_multicast or ip.is_unspecified)
137
+
138
+
139
+ def _assert_public_host(url: str) -> None:
140
+ """Raise ValueError if *url*'s host resolves to any non-public address.
141
+
142
+ download_file is called with fully attacker-controlled URLs: a did:web
143
+ host, a credentialStatus list, and the OB2 badge/issuer/revocationList URLs
144
+ all come from untrusted badge data. Without this check a verifier can be
145
+ steered into GETting cloud-metadata endpoints (169.254.169.254), loopback
146
+ admin interfaces, or RFC1918 internal hosts (SSRF). Every resolved address
147
+ is checked, and the check is re-applied to redirect targets in
148
+ _HTTPSOnlyRedirectHandler (a public host can 30x to an internal address).
149
+
150
+ This is a pre-connection check; it does not on its own defeat DNS rebinding
151
+ (a name that resolves public here but private at connect time), which would
152
+ require pinning the socket to the validated address.
153
+ """
154
+ parts = urlparse(url)
155
+ host = parts.hostname
156
+ if not host:
157
+ raise ValueError('Refusing to download %s: URL has no host' % url)
158
+ try:
159
+ addrs = _resolve_host(host, parts.port or 443)
160
+ except OSError as exc:
161
+ raise ValueError(
162
+ 'Could not resolve host %r for %s: %s' % (host, url, exc)) from exc
163
+ if not addrs:
164
+ raise ValueError('Could not resolve host %r for %s' % (host, url))
165
+ for ip_str in addrs:
166
+ if _ip_is_blocked(ip_str):
167
+ raise ValueError(
168
+ 'Refusing to download %s: host %r resolves to non-public address '
169
+ '%s (possible SSRF). Pass allow_private=True to override.'
170
+ % (url, host, ip_str))
171
+
172
+
102
173
  class _HTTPSOnlyRedirectHandler(request.HTTPRedirectHandler):
103
- """Reject any redirect whose target is not HTTPS.
174
+ """Reject any redirect whose target is not HTTPS or resolves to a
175
+ non-public host.
104
176
 
105
177
  Plain ``urlopen()`` follows redirects with the default HTTPRedirectHandler,
106
- which never re-checks scheme: an https:// URL that 302s to http:// (or to
107
- an attacker-chosen insecure origin) would be followed transparently,
108
- defeating the HTTPS-only check below.
178
+ which never re-checks scheme or host: an https:// URL that 302s to http://
179
+ (or to an attacker-chosen insecure/internal origin) would be followed
180
+ transparently, defeating the HTTPS-only and public-host checks below.
109
181
  """
110
182
 
111
- def __init__(self, allow_insecure: bool) -> None:
183
+ def __init__(self, allow_insecure: bool, allow_private: bool = False) -> None:
112
184
  self._allow_insecure = allow_insecure
185
+ self._allow_private = allow_private
113
186
 
114
187
  def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any,
115
188
  newurl: str) -> Any:
@@ -119,6 +192,8 @@ class _HTTPSOnlyRedirectHandler(request.HTTPRedirectHandler):
119
192
  'Refusing to follow redirect to %s over insecure %r scheme; HTTPS is '
120
193
  'required (pass allow_insecure=True to override).'
121
194
  % (newurl, new_scheme))
195
+ if not self._allow_private:
196
+ _assert_public_host(newurl)
122
197
  return super().redirect_request(req, fp, code, msg, headers, newurl)
123
198
 
124
199
 
@@ -128,15 +203,23 @@ class _HTTPSOnlyRedirectHandler(request.HTTPRedirectHandler):
128
203
  MAX_DOWNLOAD_SIZE = 5 * 1024 * 1024 # 5 MiB
129
204
 
130
205
 
131
- def download_file(url: str, allow_insecure: bool = False) -> bytes:
206
+ def download_file(url: str, allow_insecure: bool = False,
207
+ allow_private: bool = False) -> bytes:
132
208
  """Download a file over HTTPS using urllib's default TLS validation.
133
209
 
134
210
  Non-HTTPS URLs are rejected by default: the verification key is the
135
211
  OpenBadges 2.0 root of trust, so fetching it over an unauthenticated
136
212
  channel would let an active network attacker substitute their own key and
137
213
  forge badges. Pass ``allow_insecure=True`` to explicitly permit plain HTTP.
138
- A redirect to a non-HTTPS target is rejected the same way. The response
139
- body is bounded to MAX_DOWNLOAD_SIZE to limit memory use.
214
+ A redirect to a non-HTTPS target is rejected the same way.
215
+
216
+ The destination host is also required to resolve to a public address:
217
+ because the URL is attacker-influenced (a did:web host, a credentialStatus
218
+ list, an OB2 badge/issuer/revocationList URL), fetching a private/loopback/
219
+ link-local target would be a server-side request forgery (SSRF) sink. Pass
220
+ ``allow_private=True`` to permit internal hosts (e.g. a private deployment).
221
+ The check is re-applied to redirect targets. The response body is bounded to
222
+ MAX_DOWNLOAD_SIZE to limit memory use.
140
223
  """
141
224
  u = urlparse(url)
142
225
 
@@ -148,7 +231,11 @@ def download_file(url: str, allow_insecure: bool = False) -> bytes:
148
231
  % (url, u.scheme))
149
232
  print('Warning! %s does not use TLS.' % url)
150
233
 
151
- opener = request.build_opener(_HTTPSOnlyRedirectHandler(allow_insecure))
234
+ if not allow_private:
235
+ _assert_public_host(url)
236
+
237
+ opener = request.build_opener(
238
+ _HTTPSOnlyRedirectHandler(allow_insecure, allow_private))
152
239
  with opener.open(url, timeout=30) as response:
153
240
  chunks = []
154
241
  total = 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 1.2.0
3
+ Version: 1.3.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
@@ -82,6 +82,86 @@ def test_ob3_no_key_json(tmp_path, rsa_priv_pem, svg_image, capsys):
82
82
  assert 'requires' in result['reason']
83
83
 
84
84
 
85
+ # ── OB3 --resolve-did trust semantics ─────────────────────────────────────────
86
+
87
+ _B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
88
+
89
+
90
+ def _b58encode(data: bytes) -> str:
91
+ n = int.from_bytes(data, 'big')
92
+ out = ''
93
+ while n > 0:
94
+ n, r = divmod(n, 58)
95
+ out = _B58[r] + out
96
+ pad = len(data) - len(data.lstrip(b'\x00'))
97
+ return '1' * pad + out
98
+
99
+
100
+ def _did_key_ed25519(pub) -> str:
101
+ from cryptography.hazmat.primitives import serialization as ser
102
+ raw = pub.public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw)
103
+ return 'did:key:z' + _b58encode(b'\xed\x01' + raw)
104
+
105
+
106
+ def _ob3_did_badge(tmp_path, ed25519_priv_pem, svg_image, issuer_did):
107
+ from openbadgeslib.ob3 import OB3Signer, Issuer, Achievement, OpenBadgeCredential
108
+ signer = OB3Signer(privkey_pem=ed25519_priv_pem, algorithm='EdDSA')
109
+ cred = OpenBadgeCredential(
110
+ issuer=Issuer(id=issuer_did, name='Self Issuer'),
111
+ recipient_id='mailto:recipient@example.com',
112
+ achievement=Achievement(id='https://example.com/a', name='A',
113
+ description='d', criteria_narrative='c'),
114
+ )
115
+ badge_file = tmp_path / 'badge.svg'
116
+ badge_file.write_bytes(signer.sign_into_svg(cred, svg_image))
117
+ return badge_file
118
+
119
+
120
+ def test_ob3_resolve_did_key_is_untrusted_json(
121
+ tmp_path, ed25519_priv_pem, ed25519_pub_pem, svg_image, capsys
122
+ ):
123
+ # --resolve-did on a did:key reads the verification key from the token
124
+ # itself: the signature is valid but self-asserted (the presenter chose the
125
+ # key), so it must report trusted:false and exit 2 — never an exit-0
126
+ # "verified", mirroring the OB2 badge-embedded-key case.
127
+ from cryptography.hazmat.primitives import serialization as ser
128
+ did = _did_key_ed25519(ser.load_pem_public_key(ed25519_pub_pem))
129
+ badge = _ob3_did_badge(tmp_path, ed25519_priv_pem, svg_image, did)
130
+ argv = ['openbadges-verifier', '-i', str(badge), '-r', 'recipient@example.com',
131
+ '-V', '3', '--resolve-did', '--json']
132
+ code, result = _run(argv, capsys)
133
+ assert code == 2
134
+ assert result['valid'] is True
135
+ assert result['trusted'] is False
136
+ assert result['issuer_did'] == did
137
+
138
+
139
+ def test_ob3_resolve_did_web_is_trusted_json(
140
+ tmp_path, ed25519_priv_pem, ed25519_pub_pem, svg_image, capsys
141
+ ):
142
+ # A did:web issuer is anchored on DNS + TLS, so a resolved-and-verified
143
+ # credential is trusted:true and exits 0.
144
+ import base64
145
+ from cryptography.hazmat.primitives import serialization as ser
146
+ pub = ser.load_pem_public_key(ed25519_pub_pem)
147
+ raw = pub.public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw)
148
+ x = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
149
+ did = 'did:web:issuer.example'
150
+ doc = {"id": did, "verificationMethod": [
151
+ {"id": did + "#k", "type": "JsonWebKey2020", "controller": did,
152
+ "publicKeyJwk": {"kty": "OKP", "crv": "Ed25519", "x": x}}]}
153
+ badge = _ob3_did_badge(tmp_path, ed25519_priv_pem, svg_image, did)
154
+ argv = ['openbadges-verifier', '-i', str(badge), '-r', 'recipient@example.com',
155
+ '-V', '3', '--resolve-did', '--json']
156
+ code, result = _run(argv, capsys, extra_patches=(
157
+ patch('openbadgeslib.ob3.did.download_file',
158
+ return_value=json.dumps(doc).encode('utf-8')),
159
+ ))
160
+ assert code == 0
161
+ assert result['valid'] is True
162
+ assert result['trusted'] is True
163
+
164
+
85
165
  # ── OB2 ──────────────────────────────────────────────────────────────────────
86
166
 
87
167
  def _make_signed_ob2_svg(tmp_path, badge, identity='recipient@example.com'):
@@ -112,6 +192,11 @@ def test_ob2_valid_trusted_json(tmp_path, svg_rsa_badge, rsa_pub_pem, capsys):
112
192
 
113
193
 
114
194
  def test_ob2_untrusted_is_valid_but_not_trusted_json(tmp_path, svg_rsa_badge, rsa_pub_pem, capsys):
195
+ # Verified against the badge-embedded key (no --local/--pubkey): the
196
+ # signature is internally consistent but the issuer is not anchored, so the
197
+ # process must NOT exit 0 (which automation reads as "verified"). Exit 2
198
+ # signals "valid signature, untrusted issuer"; the JSON still reports the
199
+ # detail. Exit 0 here would let a self-signed forgery pass a CI gate.
115
200
  badge_file = _make_signed_ob2_svg(tmp_path, svg_rsa_badge)
116
201
  argv = ['openbadges-verifier', '-i', str(badge_file), '-r', 'recipient@example.com',
117
202
  '-V', '2', '--json']
@@ -119,7 +204,7 @@ def test_ob2_untrusted_is_valid_but_not_trusted_json(tmp_path, svg_rsa_badge, rs
119
204
  patch('openbadgeslib.ob2.badge.download_file', return_value=rsa_pub_pem),
120
205
  patch('openbadgeslib.ob2.verifier.download_file', side_effect=_fake_revocation_download),
121
206
  ))
122
- assert code == 0
207
+ assert code == 2
123
208
  assert result['valid'] is True
124
209
  assert result['trusted'] is False
125
210
 
@@ -31,6 +31,56 @@ class TestReadConfBaseValidation:
31
31
  conf = ConfParser(path).read_conf()
32
32
  assert conf['paths']['base'] == str(tmp_path)
33
33
 
34
+ def test_relative_base_with_suffix_keeps_suffix(self, tmp_path):
35
+ # './data' must resolve to <config_dir>/data, not be truncated to
36
+ # <config_dir> (which would silently misplace keys/logs/images).
37
+ import os
38
+ path = _write(tmp_path, '[paths]\nbase = ./data\n')
39
+ conf = ConfParser(path).read_conf()
40
+ assert conf['paths']['base'] == os.path.join(str(tmp_path), 'data')
41
+
42
+ def test_parent_relative_base_is_resolved(self, tmp_path):
43
+ import os
44
+ path = _write(tmp_path, '[paths]\nbase = ../shared\n')
45
+ conf = ConfParser(path).read_conf()
46
+ assert conf['paths']['base'] == os.path.abspath(
47
+ os.path.join(str(tmp_path), '..', 'shared'))
48
+
49
+ def test_plain_relative_base_anchored_to_config_dir(self, tmp_path):
50
+ # A relative base with no leading dot is anchored to the config-file
51
+ # directory, not left relative to the process CWD.
52
+ import os
53
+ path = _write(tmp_path, '[paths]\nbase = badgedata\n')
54
+ conf = ConfParser(path).read_conf()
55
+ assert conf['paths']['base'] == os.path.join(str(tmp_path), 'badgedata')
56
+
57
+ def test_hidden_dir_base_is_not_truncated(self, tmp_path):
58
+ # A directory literally named '.hidden' must be kept, not dropped by the
59
+ # old base[0] == '.' shortcut.
60
+ import os
61
+ path = _write(tmp_path, '[paths]\nbase = .hidden\n')
62
+ conf = ConfParser(path).read_conf()
63
+ assert conf['paths']['base'] == os.path.join(str(tmp_path), '.hidden')
64
+
65
+ def test_absolute_base_is_left_unchanged(self, tmp_path):
66
+ abs_base = str(tmp_path / 'keys')
67
+ path = _write(tmp_path, '[paths]\nbase = %s\n' % abs_base)
68
+ conf = ConfParser(path).read_conf()
69
+ assert conf['paths']['base'] == abs_base
70
+
71
+ def test_config_dir_with_dollar_sign_round_trips(self, tmp_path):
72
+ # A config directory whose path contains a literal '$' must not raise
73
+ # (ExtendedInterpolation escaping) and must resolve back to the original
74
+ # path, including through a ${base} reference.
75
+ import os
76
+ d = tmp_path / 'a$b'
77
+ d.mkdir()
78
+ p = d / 'config.ini'
79
+ p.write_text('[paths]\nbase = data\nbase_key = ${base}/keys\n')
80
+ conf = ConfParser(str(p)).read_conf()
81
+ assert conf['paths']['base'] == os.path.join(str(d), 'data')
82
+ assert conf['paths']['base_key'] == os.path.join(str(d), 'data', 'keys')
83
+
34
84
  def test_nonexistent_file_returns_none(self, tmp_path):
35
85
  assert ConfParser(str(tmp_path / 'missing.ini')).read_conf() is None
36
86
 
@@ -189,3 +189,72 @@ class TestForIssuerDid:
189
189
  verifier = OB3Verifier.for_issuer_did(wrong_did)
190
190
  with pytest.raises(OB3VerificationError):
191
191
  verifier.verify(token)
192
+
193
+ def _did_web_key_doc(self, ed25519_pub_pem, did):
194
+ pub = ser.load_pem_public_key(ed25519_pub_pem)
195
+ raw = pub.public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw)
196
+ jwk = {"kty": "OKP", "crv": "Ed25519", "x": _b64url(raw)}
197
+ return {
198
+ "id": did,
199
+ "verificationMethod": [
200
+ {"id": did + "#key-1", "type": "JsonWebKey2020",
201
+ "controller": did, "publicKeyJwk": jwk},
202
+ ],
203
+ }
204
+
205
+ def test_did_web_issuer_spoofing_rejected(self, ed25519_priv_pem, ed25519_pub_pem):
206
+ # did:web is NOT self-certifying: an attacker who controls
207
+ # did:web:attacker.example (and its key) signs a credential that CLAIMS
208
+ # a trusted issuer. Anchoring on the attacker DID resolves their key and
209
+ # the signature is valid, so binding the credential's issuer id to the
210
+ # anchored DID is the only thing that rejects the forgery.
211
+ from openbadgeslib.ob3 import OB3Signer, Achievement, Issuer, OpenBadgeCredential
212
+ attacker_did = 'did:web:attacker.example'
213
+ doc = self._did_web_key_doc(ed25519_pub_pem, attacker_did)
214
+ credential = OpenBadgeCredential(
215
+ id='urn:uuid:00000000-0000-0000-0000-0000000000ce',
216
+ issuer=Issuer(id='did:web:trusted-university.example', name='Trusted'),
217
+ recipient_id='mailto:r@example.com',
218
+ achievement=Achievement(id='https://a.example/1', name='A',
219
+ description='d', criteria_narrative='c'),
220
+ )
221
+ token = OB3Signer(privkey_pem=ed25519_priv_pem, algorithm='EdDSA').sign(credential)
222
+ verifier = OB3Verifier.for_issuer_did(
223
+ attacker_did, download=lambda url: json.dumps(doc).encode('utf-8'))
224
+ with pytest.raises(OB3VerificationError, match='issuer'):
225
+ verifier.verify(token)
226
+
227
+ def test_did_web_matching_issuer_verifies(self, ed25519_priv_pem, ed25519_pub_pem):
228
+ # The honest case: the credential's issuer id equals the anchored DID.
229
+ from openbadgeslib.ob3 import OB3Signer, Achievement, Issuer, OpenBadgeCredential
230
+ did = 'did:web:issuer.example'
231
+ doc = self._did_web_key_doc(ed25519_pub_pem, did)
232
+ credential = OpenBadgeCredential(
233
+ id='urn:uuid:00000000-0000-0000-0000-0000000000cf',
234
+ issuer=Issuer(id=did, name='Issuer'),
235
+ recipient_id='mailto:r@example.com',
236
+ achievement=Achievement(id='https://a.example/1', name='A',
237
+ description='d', criteria_narrative='c'),
238
+ )
239
+ token = OB3Signer(privkey_pem=ed25519_priv_pem, algorithm='EdDSA').sign(credential)
240
+ verifier = OB3Verifier.for_issuer_did(
241
+ did, download=lambda url: json.dumps(doc).encode('utf-8'))
242
+ cred = verifier.verify(token)
243
+ assert cred.issuer.id == did
244
+
245
+ def test_direct_pubkey_verifier_does_not_bind_issuer(
246
+ self, ed25519_priv_pem, ed25519_pub_pem
247
+ ):
248
+ # A verifier built straight from a key (no DID anchor) performs no
249
+ # issuer binding — the caller vouches for the key's owner.
250
+ from openbadgeslib.ob3 import OB3Signer, Achievement, Issuer, OpenBadgeCredential
251
+ credential = OpenBadgeCredential(
252
+ id='urn:uuid:00000000-0000-0000-0000-0000000000d0',
253
+ issuer=Issuer(id='did:web:anything.example', name='Whoever'),
254
+ recipient_id='mailto:r@example.com',
255
+ achievement=Achievement(id='https://a.example/1', name='A',
256
+ description='d', criteria_narrative='c'),
257
+ )
258
+ token = OB3Signer(privkey_pem=ed25519_priv_pem, algorithm='EdDSA').sign(credential)
259
+ cred = OB3Verifier(pubkey_pem=ed25519_pub_pem).verify(token)
260
+ assert cred.issuer.id == 'did:web:anything.example'
@@ -104,6 +104,23 @@ class TestCheckCredentialStatus:
104
104
  with pytest.raises(OB3VerificationError):
105
105
  check_credential_status(cred, download=lambda url: jwt_like.encode('ascii'))
106
106
 
107
+ def test_multibit_statussize_is_rejected(self):
108
+ # statusSize > 1 (multi-bit entries) is not supported: _bit_set assumes
109
+ # one bit per entry, so honouring it would read the wrong bits. Fail
110
+ # closed rather than misreport a revoked credential as valid.
111
+ doc = _status_list_doc(set_indices=[7])
112
+ doc['credentialSubject']['statusSize'] = 2
113
+ cred = _credential([_status_entry(94)])
114
+ with pytest.raises(OB3VerificationError, match='statusSize'):
115
+ check_credential_status(cred, download=_downloader(doc))
116
+
117
+ def test_explicit_statussize_one_is_accepted(self):
118
+ # An explicit statusSize of 1 is the default single-bit case.
119
+ doc = _status_list_doc(set_indices=[7])
120
+ doc['credentialSubject']['statusSize'] = 1
121
+ cred = _credential([_status_entry(94)]) # index 94 unset -> not revoked
122
+ check_credential_status(cred, download=_downloader(doc))
123
+
107
124
  # ── fail-closed paths ────────────────────────────────────────────────────
108
125
 
109
126
  def test_fetch_error_fails_closed(self):
@@ -98,6 +98,14 @@ def _mock_opener(content=b'file content', open_side_effect=None):
98
98
 
99
99
 
100
100
  class TestDownloadFile:
101
+ @pytest.fixture(autouse=True)
102
+ def _public_resolver(self):
103
+ # download_file now resolves the host and rejects non-public IPs. Pin
104
+ # resolution to a fixed public address so these behavioural tests stay
105
+ # hermetic (no DNS) — SSRF blocking is covered by TestSSRFProtection.
106
+ with patch('openbadgeslib.util._resolve_host', return_value=['93.184.216.34']):
107
+ yield
108
+
101
109
  def test_returns_bytes_on_success(self):
102
110
  with patch('openbadgeslib.util.request.build_opener', return_value=_mock_opener()):
103
111
  result = download_file('https://example.com/file.pem')
@@ -159,6 +167,13 @@ class TestDownloadFile:
159
167
  class TestHTTPSOnlyRedirectHandler:
160
168
  """Regression coverage for the redirect scheme-downgrade fix."""
161
169
 
170
+ @pytest.fixture(autouse=True)
171
+ def _public_resolver(self):
172
+ # The redirect handler now also rejects a redirect to a non-public host;
173
+ # pin resolution so the "allowed" redirect cases stay hermetic.
174
+ with patch('openbadgeslib.util._resolve_host', return_value=['93.184.216.34']):
175
+ yield
176
+
162
177
  def _handler(self, allow_insecure=False):
163
178
  from openbadgeslib.util import _HTTPSOnlyRedirectHandler
164
179
  return _HTTPSOnlyRedirectHandler(allow_insecure)
@@ -203,6 +218,67 @@ class TestHTTPSOnlyRedirectHandler:
203
218
  assert isinstance(handler, _HTTPSOnlyRedirectHandler)
204
219
 
205
220
 
221
+ class TestSSRFProtection:
222
+ """download_file must refuse attacker-controlled URLs that resolve to
223
+ non-public hosts (cloud metadata, loopback, RFC1918)."""
224
+
225
+ @pytest.mark.parametrize('ip', [
226
+ '127.0.0.1', '169.254.169.254', '10.0.0.5', '192.168.1.1',
227
+ '172.16.0.1', '0.0.0.0', '::1', 'fe80::1', 'fc00::1',
228
+ '::ffff:127.0.0.1',
229
+ # RFC 6598 carrier-grade NAT: not is_private, but not globally routable.
230
+ '100.64.0.1', '100.127.255.255',
231
+ ])
232
+ def test_private_host_rejected(self, ip):
233
+ with patch('openbadgeslib.util._resolve_host', return_value=[ip]):
234
+ with pytest.raises(ValueError):
235
+ download_file('https://internal.example/x')
236
+
237
+ def test_literal_metadata_ip_rejected(self):
238
+ # A literal IP needs no DNS: getaddrinfo returns it directly, so this
239
+ # exercises the real resolver + classifier without touching the network.
240
+ with pytest.raises(ValueError):
241
+ download_file('https://169.254.169.254/latest/meta-data/')
242
+
243
+ def test_literal_loopback_ipv6_rejected(self):
244
+ with pytest.raises(ValueError):
245
+ download_file('https://[::1]:8080/admin')
246
+
247
+ def test_percent_encoded_localhost_port_rejected(self):
248
+ # Mirrors a did:web:localhost%3A8080 URL after unquoting.
249
+ with pytest.raises(ValueError):
250
+ download_file('https://localhost:8080/issuer/did.json')
251
+
252
+ def test_public_host_allowed(self):
253
+ with patch('openbadgeslib.util._resolve_host', return_value=['93.184.216.34']), \
254
+ patch('openbadgeslib.util.request.build_opener', return_value=_mock_opener()):
255
+ assert download_file('https://example.com/f') == b'file content'
256
+
257
+ def test_any_private_address_in_set_rejects(self):
258
+ # A mixed public+private resolution (round-robin / rebinding) is refused.
259
+ with patch('openbadgeslib.util._resolve_host',
260
+ return_value=['93.184.216.34', '127.0.0.1']):
261
+ with pytest.raises(ValueError):
262
+ download_file('https://example.com/f')
263
+
264
+ def test_allow_private_opt_out(self):
265
+ with patch('openbadgeslib.util._resolve_host', return_value=['127.0.0.1']), \
266
+ patch('openbadgeslib.util.request.build_opener', return_value=_mock_opener()):
267
+ assert download_file('https://localhost/f', allow_private=True) == b'file content'
268
+
269
+ def test_redirect_to_private_host_rejected(self):
270
+ from openbadgeslib.util import _HTTPSOnlyRedirectHandler
271
+ req = MagicMock()
272
+ req.get_method.return_value = 'GET'
273
+ req.get_full_url.return_value = 'https://example.com/original'
274
+ req.unredirected_hdrs = {}
275
+ req.headers = {}
276
+ with patch('openbadgeslib.util._resolve_host', return_value=['169.254.169.254']):
277
+ with pytest.raises(ValueError):
278
+ _HTTPSOnlyRedirectHandler(allow_insecure=False).redirect_request(
279
+ req, None, 302, 'Found', {}, 'https://evil.example/x')
280
+
281
+
206
282
  class TestMisc:
207
283
  def test_show_ecc_disclaimer_does_not_raise(self, capsys):
208
284
  show_ecc_disclaimer()
File without changes
File without changes
File without changes