openbadgeslib 3.4.2__tar.gz → 3.5.1__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-3.4.2 → openbadgeslib-3.5.1}/Changelog.txt +67 -0
- {openbadgeslib-3.4.2/openbadgeslib.egg-info → openbadgeslib-3.5.1}/PKG-INFO +5 -3
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/README.md +4 -2
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/keys.py +26 -0
- openbadgeslib-3.5.1/openbadgeslib/logs.py +41 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob2/models.py +22 -3
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/credential.py +16 -1
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/did.py +10 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/eudi.py +28 -14
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status_registry.py +56 -1
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/verifier.py +9 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_keygenerator.py +16 -11
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_publish.py +28 -11
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_signer.py +28 -23
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/util.py +1 -1
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1/openbadgeslib.egg-info}/PKG-INFO +5 -3
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conftest.py +27 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob2_models.py +13 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_credential.py +16 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_did.py +6 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_eudi_sd_jwt.py +26 -1
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_signer_cli_ldp.py +4 -3
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_status_registry.py +49 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_verifier.py +23 -0
- openbadgeslib-3.4.2/openbadgeslib/logs.py +0 -85
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/LICENSE.txt +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/MANIFEST.in +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/docs/README.md +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/badge.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/baking.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/config.ini.example +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/confparser.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob1/verifier.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob2/verifier.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/__init__.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/ldp.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status_list.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_verifier.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/signer.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/verifier.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/SOURCES.txt +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/pyproject.toml +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/setup.cfg +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/config1.ini +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/Dockerfile.ob3 +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/README.md +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/images/sample1.png +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/images/sample1.svg +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/runtests.sh +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_cli_json.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_confparser.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_docs.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_eddsa.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_jws.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_key_operation.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob1_signer.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob1_verifier.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob2_verifier.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_publish_cli.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_status.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_status_list.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_util.py +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,73 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v3.5.1 - 2026-07-07
|
|
8
|
+
|
|
9
|
+
- fix(ob3): a naive datetime passed as the issuance/expiration date is now
|
|
10
|
+
assumed UTC (matching status_registry._iso_z), not local time — so
|
|
11
|
+
validFrom/validUntil no longer silently shift by the host's offset and
|
|
12
|
+
stay consistent with the nbf/exp JWT claims.
|
|
13
|
+
|
|
14
|
+
- fix(ob3): the JWT verifier now cross-checks the `jti` claim against the
|
|
15
|
+
credential `id` (OB3 §8.2 binds them). A validly signed token whose jti
|
|
16
|
+
disagrees with — or omits — the credential id is rejected, alongside the
|
|
17
|
+
existing iss/nbf/sub checks.
|
|
18
|
+
|
|
19
|
+
- fix(ob3): `_b58btc_decode` bounds its input to 128 characters before the
|
|
20
|
+
O(n^2) base58 decode, so a hostile did:key / publicKeyMultibase in a
|
|
21
|
+
fetched did:web or Data Integrity document (up to 5 MiB / 256 KiB) can no
|
|
22
|
+
longer burn CPU. No real multikey exceeds ~70 characters.
|
|
23
|
+
|
|
24
|
+
- fix(ob2): an `evidence` array (which OB 2.0 permits) is preserved when
|
|
25
|
+
parsing an Assertion instead of being silently dropped — `_iri_or_none`
|
|
26
|
+
returned None for a list. A single value still collapses to its IRI.
|
|
27
|
+
|
|
28
|
+
- fix(cli): `openbadges-signer -M/--mail-badge` now prints a clear notice
|
|
29
|
+
on the default OB3 path (it was a silent no-op there); the stale
|
|
30
|
+
`openbadges-keygenerator -t ED25519` hint on the LDP path is corrected to
|
|
31
|
+
the real `key_type = ED25519` config key; and the OB1 mail path reads its
|
|
32
|
+
`[smtp]` config inside the try, so a missing `[smtp]` section is reported
|
|
33
|
+
cleanly after signing instead of raising a traceback.
|
|
34
|
+
|
|
35
|
+
- fix(publish): `openbadges-publish -V 3` no longer aborts mid-loop when a
|
|
36
|
+
badge has an unreadable key or corrupt registry — it skips that badge
|
|
37
|
+
with a notice (like the did.json path already did), regenerates the
|
|
38
|
+
others, and exits non-zero with a summary. An urgent revocation is no
|
|
39
|
+
longer masked by an unrelated half-configured badge.
|
|
40
|
+
|
|
41
|
+
- refactor(keygen): `openbadges-keygenerator` uses `enable_debug_logging`
|
|
42
|
+
instead of the legacy `Logger` class (now removed from logs.py), so key
|
|
43
|
+
generation no longer requires the `[paths]`/`[logs]` config sections just
|
|
44
|
+
to emit console lines (they raised a raw KeyError/FileNotFoundError), and
|
|
45
|
+
a missing `[issuer] name` degrades gracefully.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
* v3.5.0 - 2026-07-07
|
|
49
|
+
|
|
50
|
+
- fix(status): serialise status-registry writes with an exclusive
|
|
51
|
+
inter-process lock. Concurrent `openbadges-signer` runs (or a signer
|
|
52
|
+
overlapping `openbadges-publish --revoke`) each did load→mutate→save with
|
|
53
|
+
no lock, so the second `save()` clobbered the first's new entry — and a
|
|
54
|
+
delivered credential missing from the registry can never be revoked. Both
|
|
55
|
+
mutation paths now run inside the new `StatusRegistry.locked(path)` context
|
|
56
|
+
manager, which holds a POSIX `fcntl` lock on a sibling `<registry>.lock`
|
|
57
|
+
file across the whole load→mutate→save (a dedicated lock file, because
|
|
58
|
+
`save()` renames the JSON and a lock on its inode would be orphaned). The
|
|
59
|
+
revoke path searches unlocked and reloads the winning registry under the
|
|
60
|
+
lock before mutating. Where `fcntl` is unavailable (non-POSIX) it degrades
|
|
61
|
+
to the previous unlocked behaviour, so single-operator use is unchanged.
|
|
62
|
+
|
|
63
|
+
- feat(eudi): the SD-JWT VC track now also signs with NIST **P-384**
|
|
64
|
+
(ES384) keys, not only Ed25519 and P-256. `issue_badge_sd_jwt` reads the
|
|
65
|
+
ECDSA curve from the key and picks openvc-core's matching backend
|
|
66
|
+
(`P256SigningKey`/`P384SigningKey`), stamping an `alg: ES384` issuer JWT
|
|
67
|
+
for a P-384 PEM; verification was already curve-agnostic. Other curves
|
|
68
|
+
(e.g. P-521) and RSA are still rejected with an actionable message. New
|
|
69
|
+
`openbadgeslib.keys.ec_curve_from_pem` helper. Note: HAIP profiles the
|
|
70
|
+
P-256 family, so P-384 badges may be refused by HAIP-strict EUDI wallets
|
|
71
|
+
— P-256 remains the recommended default.
|
|
72
|
+
|
|
73
|
+
|
|
7
74
|
* v3.4.2 - 2026-07-07
|
|
8
75
|
|
|
9
76
|
- build(eudi): the `[eudi]` extra now tracks `openvc-core>=1.8,<2` (was
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.1
|
|
4
4
|
Summary: Production-ready Python library & CLI for Open Badges 3.0 (W3C Verifiable Credentials): issue, verify, and revoke JWT-VC credentials with Bitstring Status Lists and did:web — plus strict OB 2.0 and legacy OB 1.0.
|
|
5
5
|
Author-email: Luis González Fernández <luisgf@luisgf.es>, Jesús Cea Avión <jcea@jcea.es>
|
|
6
6
|
License: LGPLv3
|
|
@@ -111,7 +111,7 @@ additive EUDI SD-JWT VC wallet track.
|
|
|
111
111
|
| Issue / verify | ✅ both | verify only | issue; partial verify | both | both (VC + VP) |
|
|
112
112
|
| OB 3.0 proof: VC-JWT (JOSE) | ✅ RS/ES/EdDSA | — | — | roadmap | ✅ not OB-aware |
|
|
113
113
|
| OB 3.0 proof: Data Integrity / LDP | ✅ issue + verify (`eddsa-rdfc-2022`) | — | issue only (Ed25519) | home-grown, non-conformant | older suites; no `rdfc-2022` in wheel |
|
|
114
|
-
| OB 3.0 as EUDI SD-JWT VC (selective disclosure) | ✅ issue + verify (Ed25519/P-256, `[eudi]` extra) | — | — | — | — |
|
|
114
|
+
| OB 3.0 as EUDI SD-JWT VC (selective disclosure) | ✅ issue + verify (Ed25519/P-256/P-384, `[eudi]` extra) | — | — | — | — |
|
|
115
115
|
| Revocation / suspension | ✅ W3C Bitstring Status List | hosted check | `1EdTechRevocationList` | ad-hoc flag | — |
|
|
116
116
|
| `did:web` (generate + resolve) | ✅ | — | — | — | resolve only |
|
|
117
117
|
| Image baking (SVG + PNG) | ✅ | unbake only | ✅ | — | — |
|
|
@@ -306,7 +306,9 @@ crypto to the generic [`openvc-core`](https://pypi.org/project/openvc-core/)
|
|
|
306
306
|
library. It does not touch the native VC-JWT / Data Integrity issuance above:
|
|
307
307
|
it is a separate credential format for wallet flows, not a third image proof.
|
|
308
308
|
Needs the `[eudi]` extra (`pip install "openbadgeslib[eudi]"`) and an Ed25519
|
|
309
|
-
(EdDSA)
|
|
309
|
+
(EdDSA), NIST P-256 (ES256) or P-384 (ES384) key — SD-JWT's algorithm set (RSA
|
|
310
|
+
is rejected). HAIP profiles the P-256 family, so P-256 stays the safe default
|
|
311
|
+
for EUDI wallets; P-384 may be refused by HAIP-strict verifiers.
|
|
310
312
|
The achievement is always disclosed; the recipient identity is *selectively
|
|
311
313
|
disclosable*, so a holder can prove the badge while withholding who they are.
|
|
312
314
|
|
|
@@ -60,7 +60,7 @@ additive EUDI SD-JWT VC wallet track.
|
|
|
60
60
|
| Issue / verify | ✅ both | verify only | issue; partial verify | both | both (VC + VP) |
|
|
61
61
|
| OB 3.0 proof: VC-JWT (JOSE) | ✅ RS/ES/EdDSA | — | — | roadmap | ✅ not OB-aware |
|
|
62
62
|
| OB 3.0 proof: Data Integrity / LDP | ✅ issue + verify (`eddsa-rdfc-2022`) | — | issue only (Ed25519) | home-grown, non-conformant | older suites; no `rdfc-2022` in wheel |
|
|
63
|
-
| OB 3.0 as EUDI SD-JWT VC (selective disclosure) | ✅ issue + verify (Ed25519/P-256, `[eudi]` extra) | — | — | — | — |
|
|
63
|
+
| OB 3.0 as EUDI SD-JWT VC (selective disclosure) | ✅ issue + verify (Ed25519/P-256/P-384, `[eudi]` extra) | — | — | — | — |
|
|
64
64
|
| Revocation / suspension | ✅ W3C Bitstring Status List | hosted check | `1EdTechRevocationList` | ad-hoc flag | — |
|
|
65
65
|
| `did:web` (generate + resolve) | ✅ | — | — | — | resolve only |
|
|
66
66
|
| Image baking (SVG + PNG) | ✅ | unbake only | ✅ | — | — |
|
|
@@ -255,7 +255,9 @@ crypto to the generic [`openvc-core`](https://pypi.org/project/openvc-core/)
|
|
|
255
255
|
library. It does not touch the native VC-JWT / Data Integrity issuance above:
|
|
256
256
|
it is a separate credential format for wallet flows, not a third image proof.
|
|
257
257
|
Needs the `[eudi]` extra (`pip install "openbadgeslib[eudi]"`) and an Ed25519
|
|
258
|
-
(EdDSA)
|
|
258
|
+
(EdDSA), NIST P-256 (ES256) or P-384 (ES384) key — SD-JWT's algorithm set (RSA
|
|
259
|
+
is rejected). HAIP profiles the P-256 family, so P-256 stays the safe default
|
|
260
|
+
for EUDI wallets; P-384 may be refused by HAIP-strict verifiers.
|
|
259
261
|
The achievement is always disclosed; the recipient identity is *selectively
|
|
260
262
|
disclosable*, so a holder can prove the badge while withholding who they are.
|
|
261
263
|
|
|
@@ -253,6 +253,32 @@ def public_jwk_from_pem(pubkey_pem: Union[str, bytes]) -> dict:
|
|
|
253
253
|
return json.loads(jwk_json)
|
|
254
254
|
|
|
255
255
|
|
|
256
|
+
def ec_curve_from_pem(pem_data: Union[str, bytes]) -> Optional[str]:
|
|
257
|
+
"""Return the NIST curve name of an elliptic-curve key PEM, else ``None``.
|
|
258
|
+
|
|
259
|
+
The name is ``cryptography``'s (``'secp256r1'`` for P-256, ``'secp384r1'``
|
|
260
|
+
for P-384, …); ``None`` if the PEM is not an EC key (RSA, Ed25519 or
|
|
261
|
+
unreadable). Accepts a public or private PEM. The SD-JWT VC (EUDI) track
|
|
262
|
+
uses this to pick the ES256 vs ES384 signing backend — the ECDSA curve
|
|
263
|
+
fixes the JOSE algorithm.
|
|
264
|
+
"""
|
|
265
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
266
|
+
data = _pem_bytes(pem_data)
|
|
267
|
+
for load in (
|
|
268
|
+
lambda: _crypto_serialization.load_pem_public_key(data),
|
|
269
|
+
lambda: _crypto_serialization.load_pem_private_key(data, password=None),
|
|
270
|
+
):
|
|
271
|
+
try:
|
|
272
|
+
key = load()
|
|
273
|
+
except Exception:
|
|
274
|
+
continue
|
|
275
|
+
if isinstance(key, (ec.EllipticCurvePublicKey,
|
|
276
|
+
ec.EllipticCurvePrivateKey)):
|
|
277
|
+
return key.curve.name
|
|
278
|
+
return None # readable, but not EC
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
256
282
|
def _is_ed25519_pem(pem_data: Union[str, bytes]) -> bool:
|
|
257
283
|
"""True if pem_data is an Ed25519 public or private key.
|
|
258
284
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
OpenBadges Library
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2014-2026, Luis González Fernández, luisgf@luisgf.es
|
|
6
|
+
Copyright (c) 2014-2026, Jesús Cea Avión, jcea@jcea.es
|
|
7
|
+
|
|
8
|
+
All rights reserved.
|
|
9
|
+
|
|
10
|
+
This library is free software; you can redistribute it and/or
|
|
11
|
+
modify it under the terms of the GNU Lesser General Public
|
|
12
|
+
License as published by the Free Software Foundation; either
|
|
13
|
+
version 3.0 of the License, or (at your option) any later version.
|
|
14
|
+
|
|
15
|
+
This library is distributed in the hope that it will be useful,
|
|
16
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
17
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
18
|
+
Lesser General Public License for more details.
|
|
19
|
+
|
|
20
|
+
You should have received a copy of the GNU Lesser General Public
|
|
21
|
+
License along with this library.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def enable_debug_logging(debug: bool = False) -> None:
|
|
29
|
+
"""Route library log messages to the console for the CLI tools.
|
|
30
|
+
|
|
31
|
+
With ``debug=True`` DEBUG-level messages are shown; otherwise only INFO and
|
|
32
|
+
above. Idempotent — safe to call once per CLI invocation (it will not stack
|
|
33
|
+
duplicate console handlers).
|
|
34
|
+
"""
|
|
35
|
+
root = logging.getLogger()
|
|
36
|
+
if not any(getattr(h, '_obl_console', False) for h in root.handlers):
|
|
37
|
+
handler: Any = logging.StreamHandler()
|
|
38
|
+
handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s"))
|
|
39
|
+
handler._obl_console = True
|
|
40
|
+
root.addHandler(handler)
|
|
41
|
+
root.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import uuid
|
|
33
33
|
from dataclasses import dataclass, field
|
|
34
34
|
from datetime import datetime, timezone
|
|
35
|
-
from typing import Any, List, Optional
|
|
35
|
+
from typing import Any, List, Optional, Union
|
|
36
36
|
|
|
37
37
|
from ..util import hash_email
|
|
38
38
|
|
|
@@ -122,6 +122,25 @@ def _iri_or_none(value: Any) -> Optional[str]:
|
|
|
122
122
|
return None
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
def _iri_or_iris(value: Any) -> Optional[Union[str, List[str]]]:
|
|
126
|
+
"""Like :func:`_iri_or_none`, but keep an array instead of dropping it.
|
|
127
|
+
|
|
128
|
+
OB 2.0 lets ``evidence`` be a bare IRI, an object with an ``id``, or an
|
|
129
|
+
array of either. A single value collapses to its IRI string (unchanged
|
|
130
|
+
behaviour); an array yields the list of IRIs it carries. None when nothing
|
|
131
|
+
usable is present — so a conformant multi-evidence assertion is no longer
|
|
132
|
+
silently discarded on parse.
|
|
133
|
+
"""
|
|
134
|
+
if isinstance(value, list):
|
|
135
|
+
iris = []
|
|
136
|
+
for item in value:
|
|
137
|
+
iri = _iri_or_none(item)
|
|
138
|
+
if iri is not None:
|
|
139
|
+
iris.append(iri)
|
|
140
|
+
return iris or None
|
|
141
|
+
return _iri_or_none(value)
|
|
142
|
+
|
|
143
|
+
|
|
125
144
|
def hash_identity(email: str, salt: Optional[str]) -> str:
|
|
126
145
|
"""Return the ``sha256$<hex>`` IdentityHash for an email plus optional salt."""
|
|
127
146
|
return "sha256$" + hash_email(email, salt if salt is not None else "").decode("ascii")
|
|
@@ -209,7 +228,7 @@ class Assertion:
|
|
|
209
228
|
issued_on: Optional[datetime] = None # defaults to now (UTC)
|
|
210
229
|
expires: Optional[datetime] = None
|
|
211
230
|
image: Optional[str] = None
|
|
212
|
-
evidence: Optional[str] = None
|
|
231
|
+
evidence: Optional[Union[str, List[str]]] = None
|
|
213
232
|
narrative: Optional[str] = None
|
|
214
233
|
|
|
215
234
|
def __post_init__(self) -> None:
|
|
@@ -265,7 +284,7 @@ class Assertion:
|
|
|
265
284
|
issued_on=issued_on,
|
|
266
285
|
expires=expires,
|
|
267
286
|
image=_iri_or_none(d.get("image")),
|
|
268
|
-
evidence=
|
|
287
|
+
evidence=_iri_or_iris(d.get("evidence")),
|
|
269
288
|
narrative=d.get("narrative") if isinstance(d.get("narrative"), str) else None,
|
|
270
289
|
)
|
|
271
290
|
|
|
@@ -56,7 +56,14 @@ def _validate_context(ctx: Any) -> None:
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
def _iso(dt: datetime) -> str:
|
|
59
|
-
"""Return a datetime as an ISO 8601 string with Z suffix.
|
|
59
|
+
"""Return a datetime as an ISO 8601 string with Z suffix.
|
|
60
|
+
|
|
61
|
+
A naive datetime is assumed to be UTC (matching status_registry._iso_z),
|
|
62
|
+
not local time — so validFrom/validUntil never silently shift by the host's
|
|
63
|
+
offset and stay consistent with the nbf/exp JWT claims.
|
|
64
|
+
"""
|
|
65
|
+
if dt.tzinfo is None:
|
|
66
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
60
67
|
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
61
68
|
|
|
62
69
|
|
|
@@ -131,6 +138,14 @@ class OpenBadgeCredential:
|
|
|
131
138
|
self.id = f"urn:uuid:{uuid.uuid4()}"
|
|
132
139
|
if self.issuance_date is None:
|
|
133
140
|
self.issuance_date = datetime.now(timezone.utc)
|
|
141
|
+
elif self.issuance_date.tzinfo is None:
|
|
142
|
+
# A naive datetime is assumed UTC, so validFrom (string) and nbf
|
|
143
|
+
# (epoch int) agree instead of diverging by the host's offset.
|
|
144
|
+
self.issuance_date = self.issuance_date.replace(tzinfo=timezone.utc)
|
|
145
|
+
if self.expiration_date is not None \
|
|
146
|
+
and self.expiration_date.tzinfo is None:
|
|
147
|
+
self.expiration_date = self.expiration_date.replace(
|
|
148
|
+
tzinfo=timezone.utc)
|
|
134
149
|
if self.name is None:
|
|
135
150
|
self.name = self.achievement.name
|
|
136
151
|
|
|
@@ -95,7 +95,17 @@ def _multicodec_pubkey_to_pem(data: bytes) -> bytes:
|
|
|
95
95
|
raise OB3VerificationError("unsupported did:key multicodec 0x%x" % code)
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
#: Bound on base58btc input length. Decoding is O(n^2) in the length (big-int
|
|
99
|
+
#: accumulation), and a hostile did:key / publicKeyMultibase in a fetched
|
|
100
|
+
#: document could be hundreds of KB; no real multikey exceeds ~70 characters.
|
|
101
|
+
_MAX_B58_LEN = 128
|
|
102
|
+
|
|
103
|
+
|
|
98
104
|
def _b58btc_decode(value: str) -> bytes:
|
|
105
|
+
if len(value) > _MAX_B58_LEN:
|
|
106
|
+
raise OB3VerificationError(
|
|
107
|
+
"base58btc value too long: %d characters (max %d)"
|
|
108
|
+
% (len(value), _MAX_B58_LEN))
|
|
99
109
|
num = 0
|
|
100
110
|
for ch in value:
|
|
101
111
|
idx = _B58_INDEX.get(ch)
|
|
@@ -31,14 +31,15 @@
|
|
|
31
31
|
# withholding their identity.
|
|
32
32
|
#
|
|
33
33
|
# Requires the optional ``[eudi]`` extra (``pip install openbadgeslib[eudi]``),
|
|
34
|
-
# which pulls ``openvc-core``. SD-JWT allows only Ed25519 (EdDSA) and NIST
|
|
35
|
-
# (ES256)
|
|
34
|
+
# which pulls ``openvc-core``. SD-JWT allows only Ed25519 (EdDSA) and the NIST
|
|
35
|
+
# curves P-256 (ES256) / P-384 (ES384) — RSA is not in its algorithm set.
|
|
36
36
|
|
|
37
37
|
from typing import Any, Iterable, Optional
|
|
38
38
|
|
|
39
39
|
from .credential import OpenBadgeCredential
|
|
40
40
|
from ..errors import LibOpenBadgesException
|
|
41
|
-
from ..keys import
|
|
41
|
+
from ..keys import (
|
|
42
|
+
KeyType, detect_key_type, ec_curve_from_pem, public_jwk_from_pem)
|
|
42
43
|
|
|
43
44
|
# A vct (Verifiable Credential Type) for Open Badges expressed as SD-JWT VC.
|
|
44
45
|
OB3_SD_JWT_VCT = "https://purl.imsglobal.org/spec/ob/v3p0#OpenBadgeCredential"
|
|
@@ -58,24 +59,37 @@ class EudiError(LibOpenBadgesException):
|
|
|
58
59
|
def _require_openvc() -> Any:
|
|
59
60
|
"""Import the openvc-core pieces, or raise with an actionable hint."""
|
|
60
61
|
try:
|
|
61
|
-
from openvc.keys import
|
|
62
|
+
from openvc.keys import (
|
|
63
|
+
Ed25519SigningKey, P256SigningKey, P384SigningKey)
|
|
62
64
|
from openvc.proof.sd_jwt import SdJwtVcProofSuite
|
|
63
65
|
except ImportError as exc:
|
|
64
66
|
raise EudiError(_INSTALL_HINT) from exc
|
|
65
|
-
return Ed25519SigningKey, P256SigningKey, SdJwtVcProofSuite
|
|
67
|
+
return Ed25519SigningKey, P256SigningKey, P384SigningKey, SdJwtVcProofSuite
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def _signing_key(privkey_pem: Any, kid: str) -> Any:
|
|
69
|
-
"""Build the openvc SigningKey matching the PEM's key type
|
|
70
|
-
|
|
71
|
+
"""Build the openvc SigningKey matching the PEM's key type / curve.
|
|
72
|
+
|
|
73
|
+
Ed25519 -> EdDSA; NIST P-256 -> ES256; NIST P-384 -> ES384. The ECDSA
|
|
74
|
+
curve is read from the key itself, since it fixes the JOSE algorithm.
|
|
75
|
+
"""
|
|
76
|
+
Ed25519SigningKey, P256SigningKey, P384SigningKey, _ = _require_openvc()
|
|
71
77
|
key_type = detect_key_type(privkey_pem)
|
|
72
78
|
if key_type is KeyType.ED25519:
|
|
73
79
|
return Ed25519SigningKey.from_pem(privkey_pem, kid=kid)
|
|
74
|
-
if key_type is KeyType.ECC: # NIST
|
|
75
|
-
|
|
80
|
+
if key_type is KeyType.ECC: # NIST curve -> ES256/ES384
|
|
81
|
+
curve = ec_curve_from_pem(privkey_pem)
|
|
82
|
+
if curve == "secp256r1": # P-256 -> ES256
|
|
83
|
+
return P256SigningKey.from_pem(privkey_pem, kid=kid)
|
|
84
|
+
if curve == "secp384r1": # P-384 -> ES384
|
|
85
|
+
return P384SigningKey.from_pem(privkey_pem, kid=kid)
|
|
86
|
+
raise EudiError(
|
|
87
|
+
"SD-JWT VC over ECDSA needs a NIST P-256 (ES256) or P-384 (ES384) "
|
|
88
|
+
"key; got curve %r." % curve)
|
|
76
89
|
raise EudiError(
|
|
77
|
-
"SD-JWT VC allows only Ed25519 (EdDSA) or NIST P-256 (ES256
|
|
78
|
-
"%s (RSA is not in the SD-JWT algorithm set)."
|
|
90
|
+
"SD-JWT VC allows only Ed25519 (EdDSA) or NIST P-256/P-384 (ES256/"
|
|
91
|
+
"ES384) keys; got %s (RSA is not in the SD-JWT algorithm set)."
|
|
92
|
+
% key_type.value)
|
|
79
93
|
|
|
80
94
|
|
|
81
95
|
def badge_to_sd_jwt_claims(credential: OpenBadgeCredential) -> dict:
|
|
@@ -117,9 +131,9 @@ def issue_badge_sd_jwt(
|
|
|
117
131
|
Returns the compact SD-JWT (``<issuer-jwt>~<disclosure>~…``). Only claims in
|
|
118
132
|
*disclosable* that are actually present are made selectively disclosable.
|
|
119
133
|
Pass *holder_jwk* to bind the credential to a holder key (``cnf``) for a later
|
|
120
|
-
Key-Binding presentation. Ed25519
|
|
134
|
+
Key-Binding presentation. Ed25519, P-256 or P-384 keys only.
|
|
121
135
|
"""
|
|
122
|
-
_, _, SdJwtVcProofSuite = _require_openvc()
|
|
136
|
+
_, _, _, SdJwtVcProofSuite = _require_openvc()
|
|
123
137
|
signing_key = _signing_key(privkey_pem, kid or ("%s#key-1" % credential.issuer.id))
|
|
124
138
|
claims = badge_to_sd_jwt_claims(credential)
|
|
125
139
|
present = [name for name in disclosable if name in claims]
|
|
@@ -149,7 +163,7 @@ def verify_badge_sd_jwt(
|
|
|
149
163
|
Pass *audience*/*nonce* (and ``require_key_binding=True``) to check a Key
|
|
150
164
|
Binding JWT from a holder presentation.
|
|
151
165
|
"""
|
|
152
|
-
_, _, SdJwtVcProofSuite = _require_openvc()
|
|
166
|
+
_, _, _, SdJwtVcProofSuite = _require_openvc()
|
|
153
167
|
try:
|
|
154
168
|
public_key_jwk = public_jwk_from_pem(pubkey_pem)
|
|
155
169
|
return SdJwtVcProofSuite().verify(
|
|
@@ -32,14 +32,20 @@
|
|
|
32
32
|
# leave a truncated registry behind, because a lost registry makes every
|
|
33
33
|
# outstanding credential unrevocable.
|
|
34
34
|
|
|
35
|
+
import contextlib
|
|
35
36
|
import json
|
|
36
37
|
import os
|
|
37
38
|
import secrets
|
|
38
39
|
import tempfile
|
|
39
40
|
|
|
41
|
+
try:
|
|
42
|
+
import fcntl # POSIX advisory file locking
|
|
43
|
+
except ImportError: # pragma: no cover - non-POSIX (e.g. Windows)
|
|
44
|
+
fcntl = None # type: ignore[assignment]
|
|
45
|
+
|
|
40
46
|
from dataclasses import dataclass
|
|
41
47
|
from datetime import datetime, timezone
|
|
42
|
-
from typing import Dict, List, Optional, Set
|
|
48
|
+
from typing import Dict, Iterator, List, Optional, Set
|
|
43
49
|
|
|
44
50
|
from ..errors import (
|
|
45
51
|
AlreadyRevoked,
|
|
@@ -59,6 +65,35 @@ _SCHEMA_VERSION = 1
|
|
|
59
65
|
_MAX_RANDOM_TRIES = 1000
|
|
60
66
|
|
|
61
67
|
|
|
68
|
+
@contextlib.contextmanager
|
|
69
|
+
def _exclusive_file_lock(lock_path: str) -> Iterator[None]:
|
|
70
|
+
"""Hold an exclusive inter-process lock for the duration of the block.
|
|
71
|
+
|
|
72
|
+
Backed by POSIX ``fcntl.flock`` on a dedicated lock file. Where ``fcntl``
|
|
73
|
+
is unavailable (non-POSIX platforms) it degrades to no locking — the same
|
|
74
|
+
behaviour as before this guard existed — so single-process use is
|
|
75
|
+
unaffected and nothing regresses; concurrent writers are simply not
|
|
76
|
+
serialised there.
|
|
77
|
+
"""
|
|
78
|
+
directory = os.path.dirname(lock_path) or '.'
|
|
79
|
+
umask = os.umask(0o077)
|
|
80
|
+
try:
|
|
81
|
+
os.makedirs(directory, exist_ok=True)
|
|
82
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
83
|
+
finally:
|
|
84
|
+
os.umask(umask)
|
|
85
|
+
try:
|
|
86
|
+
if fcntl is not None:
|
|
87
|
+
fcntl.flock(fd, fcntl.LOCK_EX) # blocks until the lock frees
|
|
88
|
+
yield
|
|
89
|
+
finally:
|
|
90
|
+
try:
|
|
91
|
+
if fcntl is not None:
|
|
92
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
93
|
+
finally:
|
|
94
|
+
os.close(fd)
|
|
95
|
+
|
|
96
|
+
|
|
62
97
|
@dataclass
|
|
63
98
|
class StatusEvent:
|
|
64
99
|
"""A revocation or suspension, with when and (optionally) why."""
|
|
@@ -148,6 +183,26 @@ class StatusRegistry:
|
|
|
148
183
|
raise RegistryCorrupt('%s: %s' % (path, exc)) from exc
|
|
149
184
|
return registry
|
|
150
185
|
|
|
186
|
+
@classmethod
|
|
187
|
+
@contextlib.contextmanager
|
|
188
|
+
def locked(cls, path: str,
|
|
189
|
+
size_bits: int = DEFAULT_SIZE_BITS) -> Iterator['StatusRegistry']:
|
|
190
|
+
"""Load the registry under an exclusive inter-process lock, yield it.
|
|
191
|
+
|
|
192
|
+
The lock (a sibling ``<path>.lock`` file) is held for the whole block,
|
|
193
|
+
so a ``load → allocate/revoke/… → save()`` sequence inside it is
|
|
194
|
+
atomic against other processes. This closes the race where two
|
|
195
|
+
concurrent writers both load, and the second :meth:`save` clobbers the
|
|
196
|
+
first's new entry — leaving a delivered credential unrevocable. Call
|
|
197
|
+
:meth:`save` inside the block; the lock releases on exit.
|
|
198
|
+
|
|
199
|
+
A dedicated lock file (not the registry JSON) is used on purpose:
|
|
200
|
+
:meth:`save` replaces the JSON via ``os.rename``, so a lock held on the
|
|
201
|
+
JSON inode would be orphaned by the first write.
|
|
202
|
+
"""
|
|
203
|
+
with _exclusive_file_lock(path + '.lock'):
|
|
204
|
+
yield cls.load(path, size_bits)
|
|
205
|
+
|
|
151
206
|
def save(self) -> None:
|
|
152
207
|
"""Write the registry atomically (temp file + rename) under a
|
|
153
208
|
restrictive umask; the file names recipients and is issuer-private."""
|
|
@@ -311,6 +311,15 @@ class OB3Verifier:
|
|
|
311
311
|
raise OB3VerificationError("JWT payload is missing the required 'sub' claim")
|
|
312
312
|
if sub is not None and sub != subject_id:
|
|
313
313
|
raise OB3VerificationError("JWT 'sub' does not match the credentialSubject id")
|
|
314
|
+
# jti is the registered claim bound to the credential id (OB3 §8.2); the
|
|
315
|
+
# signer always emits it. Require it whenever the body carries an id, and
|
|
316
|
+
# reject any mismatch, so a token cannot claim one id and carry another.
|
|
317
|
+
jti = payload.get("jti")
|
|
318
|
+
vc_id = vc.get("id")
|
|
319
|
+
if vc_id is not None and jti is None:
|
|
320
|
+
raise OB3VerificationError("JWT payload is missing the required 'jti' claim")
|
|
321
|
+
if jti is not None and jti != vc_id:
|
|
322
|
+
raise OB3VerificationError("JWT 'jti' does not match the credential id")
|
|
314
323
|
|
|
315
324
|
return credential
|
|
316
325
|
|
|
@@ -30,14 +30,17 @@
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
import argparse
|
|
33
|
+
import logging
|
|
33
34
|
import os
|
|
34
35
|
import sys
|
|
35
36
|
|
|
36
|
-
from .logs import
|
|
37
|
+
from .logs import enable_debug_logging
|
|
37
38
|
from .keys import KeyFactory, KeyType
|
|
38
39
|
from .confparser import read_config_or_exit, resolve_badge_section
|
|
39
40
|
from .util import __version__
|
|
40
41
|
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
41
44
|
# Entry Point
|
|
42
45
|
|
|
43
46
|
|
|
@@ -100,15 +103,17 @@ def main() -> None:
|
|
|
100
103
|
print('[!] Key file is present at %s' % i)
|
|
101
104
|
sys.exit(1)
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
# Key generation must not require [paths]/[logs]/[issuer]: the legacy Logger
|
|
107
|
+
# opened general.log/signer.log (KeyError/FileNotFoundError on an incomplete
|
|
108
|
+
# config) just to emit these console lines. The module logger needs none.
|
|
109
|
+
enable_debug_logging(args.debug)
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
logger.debug("key_type=%s private=%s public=%s",
|
|
112
|
+
key_type.name, private_key, public_key)
|
|
113
|
+
issuer_name = (conf['issuer'].get('name', '?')
|
|
114
|
+
if conf.has_section('issuer') else '?')
|
|
115
|
+
logger.info("Generating OpenBadges %s %s key pair for issuer '%s'",
|
|
116
|
+
args.ob_version, key_type.name, issuer_name)
|
|
112
117
|
|
|
113
118
|
kf = KeyFactory(key_type)
|
|
114
119
|
priv_key_pem, pub_key_pem = kf.generate_keypair()
|
|
@@ -116,8 +121,8 @@ def main() -> None:
|
|
|
116
121
|
_write_pem_file(private_key, priv_key_pem, 0o600)
|
|
117
122
|
_write_pem_file(public_key, pub_key_pem, 0o644)
|
|
118
123
|
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
logger.info('Private key saved at: %s', private_key)
|
|
125
|
+
logger.info('Public key saved at: %s', public_key)
|
|
121
126
|
|
|
122
127
|
|
|
123
128
|
if __name__ == '__main__':
|
|
@@ -200,19 +200,25 @@ def _publish_ob3(args: argparse.Namespace, parser: argparse.ArgumentParser) -> N
|
|
|
200
200
|
sys.exit(-1)
|
|
201
201
|
|
|
202
202
|
name, registry, entry = matches[0]
|
|
203
|
+
jti = entry.jti
|
|
203
204
|
now = datetime.now(tz=timezone.utc)
|
|
205
|
+
# The search above is an unlocked best-effort locator; the mutation
|
|
206
|
+
# reloads the winning registry under an exclusive lock so it is atomic
|
|
207
|
+
# against a concurrent signer or revoke (see StatusRegistry.locked).
|
|
204
208
|
try:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
with StatusRegistry.locked(registry.path,
|
|
210
|
+
registry.size_bits) as registry:
|
|
211
|
+
if op == 'revoke':
|
|
212
|
+
registry.revoke(jti, now, args.reason)
|
|
213
|
+
elif op == 'suspend':
|
|
214
|
+
registry.suspend(jti, now, args.reason)
|
|
215
|
+
else:
|
|
216
|
+
registry.unsuspend(jti)
|
|
217
|
+
registry.save()
|
|
218
|
+
except (StatusError, OSError) as exc:
|
|
213
219
|
print('[!] %s' % exc)
|
|
214
220
|
sys.exit(-1)
|
|
215
|
-
print('[+] %s %s %s (index %d)' % (verb, name,
|
|
221
|
+
print('[+] %s %s %s (index %d)' % (verb, name, jti, entry.index))
|
|
216
222
|
|
|
217
223
|
# ── regenerate every managed artefact from the registries ───────────────
|
|
218
224
|
publish_url = conf['issuer']['publish_url']
|
|
@@ -225,6 +231,7 @@ def _publish_ob3(args: argparse.Namespace, parser: argparse.ArgumentParser) -> N
|
|
|
225
231
|
print('[!] Cannot derive a did:web identifier: %s' % exc)
|
|
226
232
|
sys.exit(-1)
|
|
227
233
|
|
|
234
|
+
failures = [] # badges skipped (unreadable key / registry)
|
|
228
235
|
umask = os.umask(0o077) # rwx------
|
|
229
236
|
try:
|
|
230
237
|
os.makedirs(args.output, exist_ok=True)
|
|
@@ -258,8 +265,13 @@ def _publish_ob3(args: argparse.Namespace, parser: argparse.ArgumentParser) -> N
|
|
|
258
265
|
with open(conf[name]['private_key'], 'rb') as key:
|
|
259
266
|
priv_pem = key.read()
|
|
260
267
|
except (StatusError, OSError) as exc:
|
|
261
|
-
|
|
262
|
-
|
|
268
|
+
# Isolate a per-badge failure (unreadable key, corrupt registry)
|
|
269
|
+
# like the did.json skip above: don't abort the whole publish —
|
|
270
|
+
# a badge_2 mid-configuration must not make an urgent badge_1
|
|
271
|
+
# revocation appear to fail after its list was already written.
|
|
272
|
+
print('[!] Skipping [%s] status lists — %s' % (name, exc))
|
|
273
|
+
failures.append(name)
|
|
274
|
+
continue
|
|
263
275
|
algorithm = alg_for_key_type(detect_key_type(priv_pem))
|
|
264
276
|
|
|
265
277
|
badge_dir = os.path.join(args.output, name)
|
|
@@ -279,8 +291,13 @@ def _publish_ob3(args: argparse.Namespace, parser: argparse.ArgumentParser) -> N
|
|
|
279
291
|
finally:
|
|
280
292
|
os.umask(umask)
|
|
281
293
|
|
|
294
|
+
if failures:
|
|
295
|
+
print('[!] %d badge(s) skipped (see above): %s — their status lists '
|
|
296
|
+
'were NOT regenerated' % (len(failures), ', '.join(failures)))
|
|
282
297
|
print('Please configure your Web server to publish the folder %s as %s' %
|
|
283
298
|
(args.output, publish_url))
|
|
299
|
+
if failures:
|
|
300
|
+
sys.exit(1)
|
|
284
301
|
print('[i] Issuer DID: %s' % did)
|
|
285
302
|
if ':' not in did[len('did:web:'):]:
|
|
286
303
|
print('[i] A bare-host did:web resolves at '
|