openbadgeslib 3.4.2__tar.gz → 3.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/Changelog.txt +26 -0
  2. {openbadgeslib-3.4.2/openbadgeslib.egg-info → openbadgeslib-3.5.0}/PKG-INFO +5 -3
  3. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/README.md +4 -2
  4. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/keys.py +26 -0
  5. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/eudi.py +28 -14
  6. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/status_registry.py +56 -1
  7. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/openbadges_publish.py +15 -9
  8. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/openbadges_signer.py +10 -8
  9. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/util.py +1 -1
  10. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0/openbadgeslib.egg-info}/PKG-INFO +5 -3
  11. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/conftest.py +27 -0
  12. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_eudi_sd_jwt.py +26 -1
  13. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_status_registry.py +49 -0
  14. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/LICENSE.txt +0 -0
  15. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/MANIFEST.in +0 -0
  16. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/docs/README.md +0 -0
  17. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/__init__.py +0 -0
  18. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/_jws/__init__.py +0 -0
  19. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/_jws/exceptions.py +0 -0
  20. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/_jws/utils.py +0 -0
  21. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/badge.py +0 -0
  22. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/baking.py +0 -0
  23. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/config.ini.example +0 -0
  24. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/confparser.py +0 -0
  25. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/errors.py +0 -0
  26. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/logs.py +0 -0
  27. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/mail.py +0 -0
  28. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob1/__init__.py +0 -0
  29. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob1/badge.py +0 -0
  30. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob1/signer.py +0 -0
  31. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob1/verifier.py +0 -0
  32. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob2/__init__.py +0 -0
  33. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob2/models.py +0 -0
  34. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob2/signer.py +0 -0
  35. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob2/verifier.py +0 -0
  36. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/__init__.py +0 -0
  37. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  38. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  39. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  40. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  41. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  42. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  43. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  44. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/credential.py +0 -0
  45. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/did.py +0 -0
  46. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/ldp.py +0 -0
  47. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/signer.py +0 -0
  48. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/status.py +0 -0
  49. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/status_list.py +0 -0
  50. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/ob3/verifier.py +0 -0
  51. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/openbadges_init.py +0 -0
  52. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  53. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/openbadges_verifier.py +0 -0
  54. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/py.typed +0 -0
  55. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/signer.py +0 -0
  56. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib/verifier.py +0 -0
  57. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  58. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  59. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  60. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib.egg-info/requires.txt +0 -0
  61. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  62. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/pyproject.toml +0 -0
  63. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/setup.cfg +0 -0
  64. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/config1.ini +0 -0
  65. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/conformance/Dockerfile.ob3 +0 -0
  66. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/conformance/README.md +0 -0
  67. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/conformance/conftest.py +0 -0
  68. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/conformance/docker-compose.yml +0 -0
  69. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/conformance/test_official_validators.py +0 -0
  70. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/ob_schemas/README.md +0 -0
  71. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  72. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  73. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  74. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  75. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  76. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  77. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  78. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  79. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  80. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/images/sample1.png +0 -0
  81. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/images/sample1.svg +0 -0
  82. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/images/userimage01.svg +0 -0
  83. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/ldp_helpers.py +0 -0
  84. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  85. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  86. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/runtests.sh +0 -0
  87. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_cli_json.py +0 -0
  88. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_cli_smoke.py +0 -0
  89. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_confparser.py +0 -0
  90. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_docs.py +0 -0
  91. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_eddsa.py +0 -0
  92. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_jws.py +0 -0
  93. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_key_operation.py +0 -0
  94. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob1_badge_io.py +0 -0
  95. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob1_signer.py +0 -0
  96. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob1_verifier.py +0 -0
  97. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob2_cli.py +0 -0
  98. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob2_models.py +0 -0
  99. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob2_signer.py +0 -0
  100. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob2_verifier.py +0 -0
  101. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_conformance_schema.py +0 -0
  102. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_contexts.py +0 -0
  103. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_credential.py +0 -0
  104. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_did.py +0 -0
  105. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_did_doc.py +0 -0
  106. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_ldp.py +0 -0
  107. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_ldp_cli.py +0 -0
  108. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_ldp_sign.py +0 -0
  109. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_publish_cli.py +0 -0
  110. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_signer.py +0 -0
  111. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  112. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_status.py +0 -0
  113. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_status_list.py +0 -0
  114. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_ob3_verifier.py +0 -0
  115. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_sign_ecc.pem +0 -0
  116. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_sign_rsa.pem +0 -0
  117. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_util.py +0 -0
  118. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_verify_ecc.pem +0 -0
  119. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/test_verify_rsa.pem +0 -0
  120. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/withoutxmlheader.svg +0 -0
  121. {openbadgeslib-3.4.2 → openbadgeslib-3.5.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,32 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v3.5.0 - 2026-07-07
8
+
9
+ - fix(status): serialise status-registry writes with an exclusive
10
+ inter-process lock. Concurrent `openbadges-signer` runs (or a signer
11
+ overlapping `openbadges-publish --revoke`) each did load→mutate→save with
12
+ no lock, so the second `save()` clobbered the first's new entry — and a
13
+ delivered credential missing from the registry can never be revoked. Both
14
+ mutation paths now run inside the new `StatusRegistry.locked(path)` context
15
+ manager, which holds a POSIX `fcntl` lock on a sibling `<registry>.lock`
16
+ file across the whole load→mutate→save (a dedicated lock file, because
17
+ `save()` renames the JSON and a lock on its inode would be orphaned). The
18
+ revoke path searches unlocked and reloads the winning registry under the
19
+ lock before mutating. Where `fcntl` is unavailable (non-POSIX) it degrades
20
+ to the previous unlocked behaviour, so single-operator use is unchanged.
21
+
22
+ - feat(eudi): the SD-JWT VC track now also signs with NIST **P-384**
23
+ (ES384) keys, not only Ed25519 and P-256. `issue_badge_sd_jwt` reads the
24
+ ECDSA curve from the key and picks openvc-core's matching backend
25
+ (`P256SigningKey`/`P384SigningKey`), stamping an `alg: ES384` issuer JWT
26
+ for a P-384 PEM; verification was already curve-agnostic. Other curves
27
+ (e.g. P-521) and RSA are still rejected with an actionable message. New
28
+ `openbadgeslib.keys.ec_curve_from_pem` helper. Note: HAIP profiles the
29
+ P-256 family, so P-384 badges may be refused by HAIP-strict EUDI wallets
30
+ — P-256 remains the recommended default.
31
+
32
+
7
33
  * v3.4.2 - 2026-07-07
8
34
 
9
35
  - 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.0
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
 
@@ -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."""
@@ -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']
@@ -348,15 +348,17 @@ def _sign_ob3(args: argparse.Namespace, conf: configparser.ConfigParser, badge:
348
348
 
349
349
  # The registry is persisted BEFORE the badge is signed and written:
350
350
  # a signing failure leaves a harmless orphan index, while a delivered
351
- # badge missing from the registry could never be revoked.
351
+ # badge missing from the registry could never be revoked. The whole
352
+ # load→allocate→save runs under an exclusive lock so a concurrent
353
+ # signer cannot clobber this allocation (see StatusRegistry.locked).
352
354
  try:
353
- registry = StatusRegistry.load(status_conf.registry_path,
354
- status_conf.size_bits)
355
- assert credential.id is not None \
356
- and credential.issuance_date is not None
357
- status_index = registry.allocate(credential.id, recipient_id,
358
- credential.issuance_date)
359
- registry.save()
355
+ with StatusRegistry.locked(status_conf.registry_path,
356
+ status_conf.size_bits) as registry:
357
+ assert credential.id is not None \
358
+ and credential.issuance_date is not None
359
+ status_index = registry.allocate(credential.id, recipient_id,
360
+ credential.issuance_date)
361
+ registry.save()
360
362
  except (StatusError, OSError) as exc:
361
363
  print('[!] Could not allocate a status list index: %s' % exc)
362
364
  sys.exit(-1)
@@ -21,7 +21,7 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- __version__ = '3.4.2'
24
+ __version__ = '3.5.0'
25
25
 
26
26
  import hashlib
27
27
  import ipaddress
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 3.4.2
3
+ Version: 3.5.0
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
 
@@ -39,6 +39,33 @@ def ecc_pub_pem():
39
39
  return (TESTS_DIR / 'test_verify_ecc.pem').read_bytes()
40
40
 
41
41
 
42
+ @pytest.fixture(scope='session')
43
+ def p384_keypair():
44
+ # Generated in-session (no committed P-384 key material on disk), like the
45
+ # Ed25519 keypair. NIST P-384 -> ES384 on the EUDI SD-JWT track.
46
+ from cryptography.hazmat.primitives import serialization
47
+ from cryptography.hazmat.primitives.asymmetric import ec
48
+ priv = ec.generate_private_key(ec.SECP384R1())
49
+ priv_pem = priv.private_bytes(
50
+ serialization.Encoding.PEM,
51
+ serialization.PrivateFormat.PKCS8,
52
+ serialization.NoEncryption())
53
+ pub_pem = priv.public_key().public_bytes(
54
+ serialization.Encoding.PEM,
55
+ serialization.PublicFormat.SubjectPublicKeyInfo)
56
+ return priv_pem, pub_pem
57
+
58
+
59
+ @pytest.fixture(scope='session')
60
+ def p384_priv_pem(p384_keypair):
61
+ return p384_keypair[0]
62
+
63
+
64
+ @pytest.fixture(scope='session')
65
+ def p384_pub_pem(p384_keypair):
66
+ return p384_keypair[1]
67
+
68
+
42
69
  @pytest.fixture(scope='session')
43
70
  def ed25519_keypair():
44
71
  # Generated in-session (not a committed key file) so the suite ships no
@@ -39,6 +39,19 @@ class TestSdJwtBadge:
39
39
  assert result.issuer == ob3_credential.issuer.id
40
40
  assert result.claims["achievement"]["name"] == ob3_credential.achievement.name
41
41
 
42
+ def test_roundtrip_es384(self, ob3_credential, p384_priv_pem, p384_pub_pem):
43
+ import base64
44
+ import json
45
+ token = issue_badge_sd_jwt(ob3_credential, privkey_pem=p384_priv_pem)
46
+ # A P-384 key must pin the ES384 algorithm in the issuer JWT header.
47
+ header_b64 = token.split(".", 1)[0]
48
+ header = json.loads(base64.urlsafe_b64decode(header_b64 + "=="))
49
+ assert header["alg"] == "ES384"
50
+ result = verify_badge_sd_jwt(token, pubkey_pem=p384_pub_pem)
51
+ assert result.issuer == ob3_credential.issuer.id
52
+ assert result.claims["achievement"]["name"] == ob3_credential.achievement.name
53
+ assert result.claims["credentialSubject"]["id"] == ob3_credential.recipient_id
54
+
42
55
  def test_claims_shape(self, ob3_credential):
43
56
  claims = badge_to_sd_jwt_claims(ob3_credential)
44
57
  assert claims["iss"] == ob3_credential.issuer.id
@@ -100,10 +113,22 @@ class TestSdJwtBadge:
100
113
  expected_vct="https://example.com/other-type")
101
114
 
102
115
  def test_rsa_key_rejected(self, ob3_credential, rsa_priv_pem):
103
- # SD-JWT's algorithm set is {ES256, EdDSA}; RSA is not allowed.
116
+ # SD-JWT's algorithm set is {ES256, ES384, EdDSA}; RSA is not allowed.
104
117
  with pytest.raises(EudiError, match="Ed25519|P-256|RSA"):
105
118
  issue_badge_sd_jwt(ob3_credential, privkey_pem=rsa_priv_pem)
106
119
 
120
+ def test_unsupported_curve_rejected(self, ob3_credential):
121
+ # SD-JWT's ECDSA set is P-256/P-384; a P-521 key must be refused with a
122
+ # clear message rather than mis-signed under the wrong algorithm.
123
+ from cryptography.hazmat.primitives import serialization
124
+ from cryptography.hazmat.primitives.asymmetric import ec
125
+ p521 = ec.generate_private_key(ec.SECP521R1()).private_bytes(
126
+ serialization.Encoding.PEM,
127
+ serialization.PrivateFormat.PKCS8,
128
+ serialization.NoEncryption())
129
+ with pytest.raises(EudiError, match="P-256|P-384|curve"):
130
+ issue_badge_sd_jwt(ob3_credential, privkey_pem=p521)
131
+
107
132
 
108
133
  # ── behaviour without the [eudi] extra (runs with or without openvc) ─────────
109
134
 
@@ -38,6 +38,55 @@ def _registry(tmp_path, size_bits=131072):
38
38
  return StatusRegistry.load(str(tmp_path / 'badge_1.json'), size_bits)
39
39
 
40
40
 
41
+ # ── inter-process locking ────────────────────────────────────────────────────
42
+
43
+ def _alloc_worker(path, size_bits, jti, recipient, when):
44
+ # Module-level so it is importable under the 'spawn' start method. Each
45
+ # call is a separate process doing the full locked load→allocate→save.
46
+ from openbadgeslib.ob3.status_registry import StatusRegistry
47
+ with StatusRegistry.locked(path, size_bits) as registry:
48
+ registry.allocate(jti, recipient, when)
49
+ registry.save()
50
+
51
+
52
+ class TestLocked:
53
+ def test_yields_loaded_registry_and_creates_lock_file(self, tmp_path):
54
+ path = str(tmp_path / 'badge_1.json')
55
+ with StatusRegistry.locked(path, 131072) as registry:
56
+ assert registry.path == path
57
+ registry.allocate(JTI, 'mailto:a@example.org', NOW)
58
+ registry.save()
59
+ assert os.path.exists(path + '.lock') # dedicated lock file
60
+ assert JTI in StatusRegistry.load(path, 131072).entries
61
+
62
+ def test_concurrent_allocations_are_never_lost(self, tmp_path):
63
+ # Without the lock, N processes each load→allocate→save would clobber
64
+ # one another and only the last save() would survive. With it, every
65
+ # allocation must be present.
66
+ pytest.importorskip('fcntl') # POSIX-only guarantee
67
+ import multiprocessing as mp
68
+ path = str(tmp_path / 'badge_1.json')
69
+ bits = 131072
70
+ n = 6
71
+ ctx = mp.get_context('spawn') # deterministic on macOS
72
+ procs = [
73
+ ctx.Process(target=_alloc_worker,
74
+ args=(path, bits, 'urn:jti:%d' % i,
75
+ 'mailto:u%d@example.org' % i, NOW))
76
+ for i in range(n)
77
+ ]
78
+ for p in procs:
79
+ p.start()
80
+ for p in procs:
81
+ p.join(timeout=60)
82
+ assert p.exitcode == 0
83
+
84
+ registry = StatusRegistry.load(path, bits)
85
+ assert {e.jti for e in registry.entries.values()} == \
86
+ {'urn:jti:%d' % i for i in range(n)}
87
+ assert len({e.index for e in registry.entries.values()}) == n
88
+
89
+
41
90
  # ── allocation ───────────────────────────────────────────────────────────────
42
91
 
43
92
  class TestAllocate:
File without changes
File without changes
File without changes