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.
Files changed (122) hide show
  1. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/Changelog.txt +67 -0
  2. {openbadgeslib-3.4.2/openbadgeslib.egg-info → openbadgeslib-3.5.1}/PKG-INFO +5 -3
  3. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/README.md +4 -2
  4. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/keys.py +26 -0
  5. openbadgeslib-3.5.1/openbadgeslib/logs.py +41 -0
  6. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob2/models.py +22 -3
  7. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/credential.py +16 -1
  8. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/did.py +10 -0
  9. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/eudi.py +28 -14
  10. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status_registry.py +56 -1
  11. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/verifier.py +9 -0
  12. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_keygenerator.py +16 -11
  13. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_publish.py +28 -11
  14. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_signer.py +28 -23
  15. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/util.py +1 -1
  16. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1/openbadgeslib.egg-info}/PKG-INFO +5 -3
  17. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conftest.py +27 -0
  18. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob2_models.py +13 -0
  19. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_credential.py +16 -0
  20. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_did.py +6 -0
  21. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_eudi_sd_jwt.py +26 -1
  22. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_signer_cli_ldp.py +4 -3
  23. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_status_registry.py +49 -0
  24. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_verifier.py +23 -0
  25. openbadgeslib-3.4.2/openbadgeslib/logs.py +0 -85
  26. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/LICENSE.txt +0 -0
  27. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/MANIFEST.in +0 -0
  28. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/docs/README.md +0 -0
  29. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/__init__.py +0 -0
  30. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/_jws/__init__.py +0 -0
  31. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/_jws/exceptions.py +0 -0
  32. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/_jws/utils.py +0 -0
  33. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/badge.py +0 -0
  34. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/baking.py +0 -0
  35. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/config.ini.example +0 -0
  36. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/confparser.py +0 -0
  37. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/errors.py +0 -0
  38. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/mail.py +0 -0
  39. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob1/__init__.py +0 -0
  40. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob1/badge.py +0 -0
  41. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob1/signer.py +0 -0
  42. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob1/verifier.py +0 -0
  43. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob2/__init__.py +0 -0
  44. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob2/signer.py +0 -0
  45. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob2/verifier.py +0 -0
  46. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/__init__.py +0 -0
  47. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  48. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  49. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  50. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  51. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  52. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  53. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  54. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/ldp.py +0 -0
  55. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/signer.py +0 -0
  56. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status.py +0 -0
  57. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status_list.py +0 -0
  58. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_init.py +0 -0
  59. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_verifier.py +0 -0
  60. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/py.typed +0 -0
  61. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/signer.py +0 -0
  62. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib/verifier.py +0 -0
  63. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  64. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  65. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/entry_points.txt +0 -0
  66. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/requires.txt +0 -0
  67. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/top_level.txt +0 -0
  68. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/pyproject.toml +0 -0
  69. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/setup.cfg +0 -0
  70. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/config1.ini +0 -0
  71. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/Dockerfile.ob3 +0 -0
  72. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/README.md +0 -0
  73. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/conftest.py +0 -0
  74. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/docker-compose.yml +0 -0
  75. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/conformance/test_official_validators.py +0 -0
  76. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/README.md +0 -0
  77. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  78. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  79. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  80. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  81. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  82. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  83. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  84. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  85. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  86. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/images/sample1.png +0 -0
  87. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/images/sample1.svg +0 -0
  88. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/images/userimage01.svg +0 -0
  89. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/ldp_helpers.py +0 -0
  90. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/logo Python Espan/314/203a.svg" +0 -0
  91. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/logo Python Espa/303/261a.svg" +0 -0
  92. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/runtests.sh +0 -0
  93. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_cli_json.py +0 -0
  94. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_cli_smoke.py +0 -0
  95. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_confparser.py +0 -0
  96. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_docs.py +0 -0
  97. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_eddsa.py +0 -0
  98. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_jws.py +0 -0
  99. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_key_operation.py +0 -0
  100. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob1_badge_io.py +0 -0
  101. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob1_signer.py +0 -0
  102. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob1_verifier.py +0 -0
  103. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob2_cli.py +0 -0
  104. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob2_signer.py +0 -0
  105. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob2_verifier.py +0 -0
  106. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_conformance_schema.py +0 -0
  107. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_contexts.py +0 -0
  108. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_did_doc.py +0 -0
  109. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_ldp.py +0 -0
  110. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_ldp_cli.py +0 -0
  111. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_ldp_sign.py +0 -0
  112. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_publish_cli.py +0 -0
  113. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_signer.py +0 -0
  114. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_status.py +0 -0
  115. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_ob3_status_list.py +0 -0
  116. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_sign_ecc.pem +0 -0
  117. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_sign_rsa.pem +0 -0
  118. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_util.py +0 -0
  119. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_verify_ecc.pem +0 -0
  120. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/test_verify_rsa.pem +0 -0
  121. {openbadgeslib-3.4.2 → openbadgeslib-3.5.1}/tests/withoutxmlheader.svg +0 -0
  122. {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.4.2
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) or NIST P-256 (ES256) key — SD-JWT's algorithm set (RSA is rejected).
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) or NIST P-256 (ES256) key — SD-JWT's algorithm set (RSA is rejected).
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=_iri_or_none(d.get("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 P-256
35
- # (ES256) keys — RSA is not in its algorithm set.
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 KeyType, detect_key_type, public_jwk_from_pem
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 Ed25519SigningKey, P256SigningKey
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 (Ed25519 / P-256)."""
70
- Ed25519SigningKey, P256SigningKey, _ = _require_openvc()
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 P-256 -> ES256
75
- return P256SigningKey.from_pem(privkey_pem, kid=kid)
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) keys; got "
78
- "%s (RSA is not in the SD-JWT algorithm set)." % key_type.value)
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 / P-256 keys only.
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 Logger
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
- log = Logger(base_log=conf['paths']['base_log'],
104
- general=conf['logs']['general'],
105
- signer=conf['logs']['signer'],
106
- show_debug=args.debug)
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
- log.console.debug("key_type=%s private=%s public=%s"
109
- % (key_type.name, private_key, public_key))
110
- log.console.info("Generating OpenBadges %s %s key pair for issuer '%s'"
111
- % (args.ob_version, key_type.name, conf['issuer']['name']))
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
- log.console.info('Private key saved at: %s' % private_key)
120
- log.console.info('Public key saved at: %s' % public_key)
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
- if op == 'revoke':
206
- registry.revoke(entry.jti, now, args.reason)
207
- elif op == 'suspend':
208
- registry.suspend(entry.jti, now, args.reason)
209
- else:
210
- registry.unsuspend(entry.jti)
211
- registry.save()
212
- except StatusError as exc:
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, entry.jti, entry.index))
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
- print('[!] %s' % exc)
262
- sys.exit(-1)
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 '