Skip to content

Add the OKP key type (RFC 8037) and the Ed25519 and Ed448 signing algorithms (RFC 9864) - #169

Merged
TheStormN merged 2 commits into
cisco:masterfrom
OpenIDC:okp-ed25519
Sep 11, 2026
Merged

TheStormN merged 2 commits into
cisco:masterfrom
OpenIDC:okp-ed25519

Conversation

@zandbelt

@zandbelt zandbelt commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Two commits, ported from the version-1.0.x branch of OpenIDC/cjose and reworked for master:

  1. jwk: add the Octet Key Pair (OKP) key type of RFC 8037. "kty":"OKP" JWKs holding a raw Ed25519 or Ed448 signature key (RFC 8032) or an X25519 or X448 key agreement key (RFC 7748). New public API: cjose_jwk_okp_curve, cjose_jwk_okp_keyspec, cjose_jwk_create_OKP_random(), cjose_jwk_create_OKP_spec() and cjose_jwk_OKP_get_curve(), added to the three export lists; cjose_jwk_import() and cjose_jwk_to_json() handle the new type.
  2. jws: add the Ed25519 and Ed448 signing algorithms of RFC 9864. The fully-specified EdDSA identifiers over OKP keys: CJOSE_HDR_ALG_ED25519 and CJOSE_HDR_ALG_ED448.

Design notes

  • No "EdDSA". RFC 9864 sets the JOSE implementation requirements of the polymorphic "EdDSA" identifier of RFC 8037 to Deprecated in favour of Ed25519 and Ed448, so it is deliberately not supported: a JWS carrying it does not import and nothing can be signed with it (test_cjose_jws_eddsa_deprecated). Accepting it anyway, for interoperability with existing deployments, would be a three-line change in _cjose_jws_validate_hdr() and _cjose_jws_validate_okp_key().
  • Key/algorithm binding. Each identifier only accepts a key of its own curve, X25519/X448 keys are refused for signing (RFC 8037 section 3.1), an OKP key is refused for the other families, and a signature must have the fixed size of the curve (64 or 114 octets) before it is handed to OpenSSL, like the existing ECDSA 2 * coordlen check.
  • PureEdDSA. There is no pre-hash, so the digest step keeps the signing input B64U(HEADER).B64U(DATA) and signing/verification use the one-shot EVP_DigestSign() / EVP_DigestVerify(). The digest step frees a previous digest first so cjose_jws_verify() can be called repeatedly on one JWS with different candidate keys.
  • Key import. x is required (RFC 8037 section 2) and d, when present, must be a non-empty string; both must have the fixed size of the curve, and when d is given x must be the public key that belongs to it (the 1.0.x code accepted mismatched pairs). A lone d is only accepted by cjose_jwk_create_OKP_spec(), which derives the public key. The decoded d is wiped after use like the other private key material.
  • Signing needs the private key. cjose_jws_sign() refuses a public-only OKP key with CJOSE_ERR_INVALID_ARG before calling OpenSSL: OpenSSL 1.1.1 and 3.0.0 through 3.0.7 sign with the missing private key and crash instead of failing (3.0.8 added the guard), which is what the review found; Restore the OpenSSL 1.1.x build and test against every supported OpenSSL release series #170 adds those releases to CI.
  • OpenSSL. The 1.0.x code used the OpenSSL 3 OSSL_PARAM API; this port uses the OpenSSL 1.1.1 raw key API (EVP_PKEY_new_raw_private_key() & co.) and gates everything on a new CJOSE_OPENSSL_111X macro next to CJOSE_OPENSSL_11X, since master still declares OpenSSL 1.0.1 as its minimum. With an older OpenSSL the library still builds, the OKP functions fail with CJOSE_ERR_INVALID_ARG, the two identifiers are unknown, and the new tests are compiled out; verified with the gate forced off.
  • Public headers stay OpenSSL-free (Remove OpenSSL dependencies from public headers #166): the curve enum uses the literal NID values, like the EC enum.
  • Header constants are macro-only, following the ES256K precedent in Add ES256K and secp256k1 support #167.

Tests

  • check_jwk: create/spec/random for all four curves, private-only and public-only specs, mismatched x/d, wrong sizes, JSON export, import/export round trips with the RFC 8037 appendix A.1/A.2/A.6 keys and the RFC 7748 X25519 vectors, invalid JWKs (including d without x, empty x, empty or non-string d).
  • check_jws: self sign/verify for Ed25519 and Ed448 in the existing loops; fixed vectors (EdDSA signatures are deterministic; cross-checked with openssl pkeyutl -sign -rawin); tampered signature and content; wrong key type or curve for sign and verify; signing with a public-only Ed25519 or Ed448 key; "EdDSA" refused; 63 and 65 octet signatures refused.

Verified locally with -Wall -Werror, the full check_cjose run, valgrind on the jwk and jws suites, the clang-format target (no diff) and doxygen. Each commit builds and passes on its own.

Rebased onto #168, merging the two "Unreleased" CHANGELOG sections.

🤖 Generated with Claude Code

@TheStormN TheStormN left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly enough, the unit tests crash on my local machine. I had the AI do some analysis and this is what I got as output. Could you please check it out.

P1 — Reject public OKP keys before signing

src/jws.c:1322

_cjose_jws_validate_okp_key() validates only the key type and curve. A public-only OKP can therefore reach EVP_DigestSign(). With OpenSSL 3.0, this can segfault instead of returning an error.

Please explicitly verify private-key material with EVP_PKEY_get_raw_private_key() before signing and return CJOSE_ERR_INVALID_ARG or CJOSE_ERR_CRYPTO. The existing public-key regression test should complete without crashing.

P2 — Add Ed448 coverage for private-key validation

test/check_jws.c:444

The public-key signing regression currently covers Ed25519 only. Please add the equivalent Ed448 case so both new algorithms verify that public-only keys are rejected safely.

P2 — Reject empty OKP fields

The generic _decode_json_object_base64url_attribute() helper treats empty strings as absent. As a result, an OKP JWK with "d":"" can be accepted as a public key, while one with a missing or empty "x" can be accepted as a private key.

RFC 8037 requires x for every OKP JWK and requires a correctly sized d for private keys. Please validate OKP field presence separately and add malformed-input tests.

@zandbelt

Copy link
Copy Markdown
Contributor Author

I suspect an OpenSSL version compatibility issue; if so, I'll also provide a PR for CI to catch this type of thing

@TheStormN

Copy link
Copy Markdown
Contributor

I suspect an OpenSSL version compatibility issue; if so, I'll also provide a PR for CI to catch this type of thing

Ok, just as a note then that my test Linux box is Ubuntu 22.04 and OpenSSL is 3.0.2.

Add "OKP" JWKs (RFC 8037 section 2) holding a raw Ed25519 or Ed448
signature key (RFC 8032) or an X25519 or X448 key agreement key
(RFC 7748): cjose_jwk_create_OKP_random, cjose_jwk_create_OKP_spec and
cjose_jwk_OKP_get_curve, plus import through cjose_jwk_import and export
through cjose_jwk_to_json. The public key "x" and the private key "d"
must have the fixed size of the curve, when both are given "x" must be
the public key that belongs to "d", and the decoded "d" is wiped after
use like the other private key material.

The key lives in an EVP_PKEY through the raw key API of OpenSSL 1.1.1;
with an older OpenSSL the OKP functions fail with CJOSE_ERR_INVALID_ARG.

Ported from the version-1.0.x branch of OpenIDC/cjose, rewritten from
the OpenSSL 3 OSSL_PARAM API to the OpenSSL 1.1.1 raw key API.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
@zandbelt

zandbelt commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks, all three confirmed and addressed; the fixes are folded into the two commits of the force-pushed branch.

  • P1. _cjose_jws_build_sig_eddsa() now checks EVP_PKEY_get_raw_private_key() before EVP_DigestSignInit() and fails with CJOSE_ERR_INVALID_ARG on a public-only key. It copies the key into a scratch buffer that is wiped right after, because 1.1.1 only reports the absence of the private key when asked to copy it out (with a NULL buffer it just returns the length). The crash is OpenSSL's: ed25519_digest_sign / ed448_digest_sign dereference the missing private key in 1.1.1 and in 3.0.0 through 3.0.7; 3.0.8 added the PROV_R_NOT_A_PRIVATE_KEY guard, which is why ubuntu-latest (3.0.13) never saw it. Reproduced with a 3.0.7 build (SIGSEGV in SHA512_Update via ossl_ed25519_sign) and verified fixed against 1.1.1w and 3.0.7. Restore the OpenSSL 1.1.x build and test against every supported OpenSSL release series #170 adds an OpenSSL release-series matrix to CI, including those two, which fails on the previous revision of this branch and passes with this one.
  • P2, Ed448. The public-key signing test now covers both curves, expecting CJOSE_ERR_INVALID_ARG.
  • P2, empty fields. _cjose_jwk_import_OKP() requires x (RFC 8037 section 2) and rejects a d that is present but empty or not a string, instead of quietly making a public key; deriving x from a lone d is gone from the JSON import (cjose_jwk_create_OKP_spec() keeps it). test_cjose_jwk_OKP_import_invalid gained the four cases: d without x, empty x, empty d, "d": null.

Add the fully-specified EdDSA (RFC 8032) JWS algorithms "Ed25519" and
"Ed448" of RFC 9864 over OKP keys. Each identifier is bound to a key of
its own curve, the X25519/X448 key agreement keys are refused (RFC 8037
section 3.1), and a signature must have the fixed size of the curve
(64 or 114 octets) before it is handed to OpenSSL. PureEdDSA signs the
JWS signing input itself, so the digest step keeps
B64U(HEADER).B64U(DATA) and signing and verification use the one-shot
EVP_DigestSign and EVP_DigestVerify of OpenSSL 1.1.1.

The polymorphic "EdDSA" identifier of RFC 8037, which RFC 9864
deprecates in favour of Ed25519 and Ed448, is not supported: a JWS
carrying it does not import and nothing can be signed with it.

Ported from the version-1.0.x branch of OpenIDC/cjose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
@TheStormN

Copy link
Copy Markdown
Contributor

Thanks, I will merge it as soon as the CI passes. Btw, I plan to start the work on the 1.0.x release with removing old OpenSSL 1 support, breaking some ABI stuff etc. Before that I will make one more 0.7.x release. Do you have any pending stuff which you would like to be merged as part of 0.7.x?

@TheStormN
TheStormN merged commit f2e1d47 into cisco:master Sep 11, 2026
17 checks passed
@zandbelt

Copy link
Copy Markdown
Contributor Author

the only thing I'd like to see back in 0.7.x is older libcheck support - back to 0.9.4 - as was documented before since that will make it possible to compile/test/package on RHEL 7 (and RHEL 6 for that matter) which is useful for mod_auth_openidc distributions; I'll submit another PR against current master and you can decide on whether to include it or not; no other bugfixes or functions reside in our forks so 0.7.1 (or 0.8.0) would be a complete replacement/upgrade for 0.6.2.x, thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants