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.
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/Changelog.txt +46 -0
- {openbadgeslib-1.2.0/openbadgeslib.egg-info → openbadgeslib-1.3.0}/PKG-INFO +3 -2
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/README.md +2 -1
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/config.ini.example +1 -1
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/confparser.py +16 -4
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/status.py +14 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/verifier.py +28 -2
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_verifier.py +33 -7
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/util.py +98 -11
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0/openbadgeslib.egg-info}/PKG-INFO +3 -2
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_cli_json.py +86 -1
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_confparser.py +50 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_did.py +69 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_status.py +17 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_util.py +76 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/LICENSE.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/MANIFEST.in +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/docs/README.md +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/badge.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/baking.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob2/badge.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob2/verifier.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/credential.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_publish.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/openbadges_signer.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/signer.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib/verifier.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/pyproject.toml +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/setup.cfg +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/config1.ini +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/conftest.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/runtests.sh +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_badge_io.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_docs.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_jws.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_signer_operation.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_verify_operation.py +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-1.2.0 → openbadgeslib-1.3.0}/tests/withoutxmlheader.svg +0 -0
- {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.
|
|
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)
|
|
59
|
+
- RSA 2048-bit (RS256), ECC NIST P-256 (ES256), and Ed25519 (EdDSA) key support
|
|
60
60
|
- SHA-256 hashed recipient identity with salt (OB 2.0)
|
|
61
61
|
- Expiration and revocation checking
|
|
62
62
|
- Five command-line tools included
|
|
@@ -68,6 +68,7 @@ serialisation) and **OpenBadges 3.0** (W3C Verifiable Credentials / JWT-VC).
|
|
|
68
68
|
- [ecdsa](https://pypi.org/project/ecdsa/) >= 0.19
|
|
69
69
|
- [pypng](https://pypi.org/project/pypng/) >= 0.20220715.0
|
|
70
70
|
- [PyJWT[crypto]](https://pypi.org/project/PyJWT/) >= 2.8
|
|
71
|
+
- [cryptography](https://pypi.org/project/cryptography/) >= 42
|
|
71
72
|
- [defusedxml](https://pypi.org/project/defusedxml/) >= 0.7
|
|
72
73
|
|
|
73
74
|
## Installation
|
|
@@ -15,7 +15,7 @@ serialisation) and **OpenBadges 3.0** (W3C Verifiable Credentials / JWT-VC).
|
|
|
15
15
|
- Sign badge images (SVG and PNG) with a JWS assertion (OB 2.0)
|
|
16
16
|
- Issue and verify OpenBadges 3.0 JWT-VC credentials
|
|
17
17
|
- Bake OB 3.0 JWT tokens into SVG and PNG badge images
|
|
18
|
-
- RSA 2048-bit (RS256)
|
|
18
|
+
- RSA 2048-bit (RS256), ECC NIST P-256 (ES256), and Ed25519 (EdDSA) key support
|
|
19
19
|
- SHA-256 hashed recipient identity with salt (OB 2.0)
|
|
20
20
|
- Expiration and revocation checking
|
|
21
21
|
- Five command-line tools included
|
|
@@ -27,6 +27,7 @@ serialisation) and **OpenBadges 3.0** (W3C Verifiable Credentials / JWT-VC).
|
|
|
27
27
|
- [ecdsa](https://pypi.org/project/ecdsa/) >= 0.19
|
|
28
28
|
- [pypng](https://pypi.org/project/pypng/) >= 0.20220715.0
|
|
29
29
|
- [PyJWT[crypto]](https://pypi.org/project/PyJWT/) >= 2.8
|
|
30
|
+
- [cryptography](https://pypi.org/project/cryptography/) >= 42
|
|
30
31
|
- [defusedxml](https://pypi.org/project/defusedxml/) >= 0.7
|
|
31
32
|
|
|
32
33
|
## Installation
|
|
@@ -44,7 +44,7 @@ verify_key = https://www.issuer.badge/issuer/badge_1/verify_rsa_key.pem
|
|
|
44
44
|
badge = https://www.issuer.badge/issuer/badge_1/badge.json
|
|
45
45
|
private_key = ${paths:base_key}/sign_rsa_key_1.pem
|
|
46
46
|
public_key = ${paths:base_key}/verify_rsa_key_1.pem
|
|
47
|
-
; key_type selects the algorithm for openbadges-keygenerator: RSA (default) or
|
|
47
|
+
; key_type selects the algorithm for openbadges-keygenerator: RSA (default), ECC, or ED25519
|
|
48
48
|
key_type = RSA
|
|
49
49
|
;alignement =
|
|
50
50
|
;tags =
|
|
@@ -87,10 +87,22 @@ class ConfParser():
|
|
|
87
87
|
raise ValueError(
|
|
88
88
|
"Configuration file %s has an empty [paths] 'base' value" % self.config_file)
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
# A relative base is resolved against the directory that contains the
|
|
91
|
+
# config file (not the process CWD), so ${base}/... paths land where the
|
|
92
|
+
# operator expects no matter where the tool is launched from. The old
|
|
93
|
+
# check only matched a bare '.' and replaced the *whole* value with the
|
|
94
|
+
# config dir — silently dropping the suffix of './data' or '../shared'
|
|
95
|
+
# (and mis-handling a real '.hidden' dir), which could redirect private
|
|
96
|
+
# keys and logs to the wrong tree with no error.
|
|
97
|
+
if not os.path.isabs(base):
|
|
98
|
+
base_dir = os.path.dirname(self.config_file)
|
|
99
|
+
resolved = os.path.abspath(os.path.join(base_dir, base))
|
|
100
|
+
# Writing back through the parser runs the value through
|
|
101
|
+
# ExtendedInterpolation, which treats '$' specially: a config
|
|
102
|
+
# directory whose absolute path contains a literal '$' would raise a
|
|
103
|
+
# raw configparser error (or silently collapse '$$' to '$'). Escape
|
|
104
|
+
# '$' -> '$$' so the path round-trips to its original form on read.
|
|
105
|
+
self.parser['paths']['base'] = resolved.replace('$', '$$')
|
|
94
106
|
|
|
95
107
|
# ExtendedInterpolation resolves ${...} references lazily, only when a
|
|
96
108
|
# value is actually read — a bad reference anywhere in the file would
|
|
@@ -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.
|
|
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
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
314
|
-
|
|
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.
|
|
24
|
+
__version__ = '1.3.0'
|
|
25
25
|
|
|
26
26
|
import hashlib
|
|
27
|
-
|
|
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://
|
|
107
|
-
an attacker-chosen insecure origin) would be followed
|
|
108
|
-
defeating the HTTPS-only
|
|
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
|
|
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.
|
|
139
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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 ==
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|