Skip to content

A 0.8.x maintenance release: six fixes backported from the 1.0 line - #195

Merged
TheStormN merged 9 commits into
cisco:0.8.xfrom
OpenIDC:version-0.8.x
Sep 13, 2026
Merged

TheStormN merged 9 commits into
cisco:0.8.xfrom
OpenIDC:version-0.8.x

Conversation

@zandbelt

@zandbelt zandbelt commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Closes #194, now that 0.8.x exists to target. Nine commits off the 0.8.0 tag, which is where this branch points, so it applies as a fast-forward.

Fixes only. No algorithm, no API, no ABI change; the OpenSSL 1.0.1 floor, the autotools build and libcjose.so.0 all stay.

commit
#191 cjose_jwe_decrypt dereferenced a NULL cjose_err on any ECDH-ES+A*KW JWE. A crash on a documented-optional argument, reachable by any application that passes NULL
#189, #192 an RSA JWK whose private member is present but valueless imported as a key it could not then export
#193 the same for a value whose octets are all zero. Still open on master — if it changes in review I will match this commit to it
#190 a multi-prime RSA JWK (oth) was silently used as if it had two primes
two ports an epk carrying a private member is refused (RFC 7518 section 4.6.1.1); the three JWE header locations must be disjoint (RFC 7516 section 7.2.1)
new a caller-supplied epk is refused in the protected header too. The guard looked the parameter up as a string and an epk is an object, so it never saw one there
new an EC JWK whose d is present but valueless is refused, the same defect #189 fixed for RSA and left for EC

The last two needed hand-porting rather than a cherry-pick: upstream squash-merged both together with things this line must not take — X25519/X448, A*GCMKW, and the crit refusal.

Deliberately left behind: the crit refusal, since rejecting input that 0.8.0 accepts is not patch-release behaviour, and every feature and build change.

What it costs a user, since a maintenance line should not surprise anyone. All of these are in the CHANGELOG:

  • A JWE repeating a member name across two header locations no longer imports. That is the point for alg, where the unauthenticated per-recipient copy won, but it also refuses harmless repetitions such as kid. Every JSON vector in test/ and the python-jwcrypto ones are disjoint, so I know of no producer that does this.
  • Supplying your own epk to an ECDH-ES encryption is refused. The key agreement overwrote it anyway, so nothing that worked stops working; a caller doing it now gets an error instead of silence.
  • Three smaller ones follow from validating the shared unprotected header at encryption time, which 0.8.0 did not do — it validated the per-recipient header twice instead. cjose_jwe_encrypt_multi now refuses a crit that header carries and cannot process, which import already refused on 0.8.0; it now accepts an alg supplied only there, where 0.8.0 required it in the protected or per-recipient header; and an EC JWK with a present-but-valueless d no longer imports.

I would rather state that second one plainly than have it found in review: it is an acceptance-widening in a fixes-only release. It falls out of passing the right header to the disjointness check, and matches what merged upstream. Say the word and I will restrict it.

No version is bumped anywhere: CMakeLists.txt, configure.ac and the generated configure all still say 0.8.0, and the CHANGELOG section is headed Unreleased for you to number at release time.

Testing

Each of the nine commits builds and passes on its own. On the tip: the full check_cjose run with CJOSE_ENABLE_RSA1_5 off (122 checks) and on (121), no clang-format diff, valgrind clean, and every fix carries a regression test verified to fail on the 0.8.0 tag — including the headline crash, which I confirmed SIGSEGVs on a build of the tag itself.

SONAME is libcjose.so.0 and the exported symbol list is byte-identical to 0.8.0. The autotools build works in tree and out of tree; test/Makefile.in is tracked on this line, so the out-of-tree include fix is applied there alongside test/Makefile.am.

🤖 Generated with Claude Code

zandbelt and others added 6 commits September 13, 2026 09:03
…ing algorithm (cisco#191)

The trailing cjose_err of the public API may be NULL, and cjose_jwe_decrypt
passes the caller's pointer straight to the key decryption function, so
_cjose_jwe_decrypt_ek_ecdh_es_kw() dereferenced it in its first statement:
decrypting any ECDH-ES+A128KW, ECDH-ES+A192KW or ECDH-ES+A256KW JWE with a
NULL err segfaulted. Its sibling for plain ECDH-ES was given a local error
object to fall back on when that path was hardened, and the key wrapping
variant was missed.

The regression test extends the existing NULL-err test over the three key
wrapping algorithms; the test binary crashes without the fix.

Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cisco#189)

_cjose_jwk_decode_json_object_base64url_attribute() reports an attribute that
is present but empty or null the same way it reports an absent one, with a NULL
buffer and a true return, so the RSA import read "d", "p", "q", "dp", "dq" and
"qi" of that shape as absent and produced a public key from a malformed one.
RFC 7517 gives each of those members a base64url value, so the key is invalid
and must be refused rather than silently reinterpreted.

The six decodes go through a helper that keeps the distinction, and the
required public members need no such guard: a valueless "n" or "e" already
fails when the key is built.

The OKP import makes the same distinction for its "d" already; the EC import
gets it in cisco#186, which can share this helper once both have landed.

Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

Refuse a private member whose value decodes to nothing

Base64url padding on its own decodes to zero octets and publishes a non-NULL
buffer, so "d": "==" slipped past a check that only looked at the pointer and
the key still imported as a public one. The guard tests the decoded length as
well, and the test covers "==" and "====" beside the empty string and null.

Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third turn at one rule: an RSA private member that is present but carries no
usable value must fail the import instead of being read as absent. cisco#189 tested
the decoded buffer pointer, cisco#192 added its length, and both let a value of
"AA" through: it decodes to octets that are all zero, which is the integer 0,
with a length of one. The predicate is now the thing it was always meant to
be, that the decoded value is zero.

What that costs a caller today: a key whose "d", "p", "q", "dp", "dq" or "qi"
is zero imports, and cjose_jwk_to_json() then writes the member back as an
empty string, so cjose cannot read its own export in again. The test covers
that directly, on a complete private key rather than on a public one with a
member appended, and asserts the round trip for the key that is accepted.

A value that merely contains a zero octet is a number like any other and keeps
importing; "AQA" and "AAE" cover both ends of the scan.

This does not make an accepted key a usable one: any other wrong "d" still
imports, signs, and produces a JWS that does not verify. Refusing that needs a
consistency check on the key rather than on one member, which is a change of a
different size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
… header locations disjoint

Two fixes from the 1.0 line that are not tied to any of its new algorithms or
to its break with OpenSSL 1.x, ported here.

RFC 7518 section 4.6.1.1 gives the "epk" header the public key parameters of
the ephemeral key and nothing else, but both ECDH-ES decryption paths took
whatever the import made of it, so an ephemeral key carrying the recipient's
own private part was accepted and the key agreement ran on it. The header is
now inspected before the import decides what its members are worth, and a "d"
member is refused whatever it holds.

RFC 7516 section 7.2.1 requires the member names of the protected, the shared
unprotected and a per-recipient unprotected header to be disjoint. They were
not checked, and _cjose_jwe_get_from_headers() resolves a name per-recipient
first, then shared, then protected, so a JWE that repeated a name was accepted
and the copy that the content encryption does not authenticate won: a
protected "alg" could be shadowed by a per-recipient one. For the encryption
side to see the shared header at all, cjose_jwe_encrypt_multi_iv() passes it
to _cjose_jwe_validate_alg() where it passed the recipient's own header, which
also lets "alg" be resolved from it as the import path already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
@zandbelt

Copy link
Copy Markdown
Contributor Author

Pushed three corrections after running an independent review over the branch. Two of them were things I had claimed in the description above that were not true, so they are worth calling out rather than leaving in the diff:

  • The autotools build still said 0.8.0, and the out-of-tree fix did not work. configure and test/Makefile.in are tracked generated files on this line. I bumped configure.ac and fixed test/Makefile.am without updating them, so ./configure reported 0.8.0, cjose.pc and version.h would have installed as 0.8.0, and the out-of-tree test build still failed with exactly the error the commit says it fixes. Both are updated now, by hand rather than by autoreconf, since my autotools are 2.72/1.18.1 and these were generated with 2.69/1.16.1 — regenerating would have buried the change in thousands of unrelated lines. In-tree and out-of-tree make check both pass.

  • "Supplying your own epk is refused" was only two thirds true. The guard looks the parameter up with _cjose_jwe_get_from_headers, which ends in json_string_value and returns NULL for anything that is not a string. An epk is an object, so it was never found and a caller's epk in the protected header was silently replaced instead of refused. The shared and per-recipient cases were caught after the fact by the disjointness check, which is why the tests passed. Fixed by backporting the JSON-valued lookup, with a test that fails without it.

  • The valueless-private-member fix was applied to RSA and left for EC. {"kty":"EC",...,"d":""} and "d":null still imported as public keys on this branch — the identical Refuse an RSA key whose private members are present but carry no value #189 defect. The commit deferred it to Add X25519 and X448 to the ECDH-ES key agreement (RFC 8037 section 3.2) #186, which has since merged upstream, so the deferral no longer holds. Fixed the same way the RSA import was.

I have also added three behaviour changes to the description and the CHANGELOG that I had not disclosed. One of them widens what cjose accepts rather than narrowing it, which deserves your attention in a fixes-only release: cjose_jwe_encrypt_multi will now produce a JWE whose alg sits only in the shared unprotected header, where 0.8.0 refused to. It falls out of passing the shared header to the disjointness check instead of the per-recipient one, which the check needs to work at all, and it matches what merged upstream — but if you would rather 0.8.x kept 0.8.0's stricter behaviour there, say so and I will add the restriction.

Nine commits now. Each builds and passes standalone; SONAME and the exported symbol list are unchanged from 0.8.0.

@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.

The PR review is good, but please remove all the versions bumps from both CMake and autotools as I'm doing that as part of the release process.

zandbelt and others added 3 commits September 13, 2026 23:31
RFC 7516 section 7.2.1 requires the member names of the protected header, the
shared unprotected header and each per-recipient unprotected header to be
disjoint. Import checks that already; encryption did not, so cjose could
produce a JWE that it would then refuse to read back.

Validate the assembled headers at the end of the encryption as well, and hand
_cjose_jwe_validate_alg the shared unprotected header rather than the
per-recipient one it was given twice, which is what the disjointness check
needs to see all three locations.

Also fix the out-of-tree autotools test build, which passed only the build
directory include path and so compiled the test against an installed copy of
the headers; test/Makefile.in is tracked on this line, so it is updated
alongside test/Makefile.am.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
The guard that keeps a caller from supplying the parameter the ECDH-ES key
agreement produces itself looked it up with _cjose_jwe_get_from_headers, which
ends in json_string_value and so returns NULL for any value that is not a
string. An "epk" is a JSON object, so the guard never saw one and the caller's
header was silently replaced instead of refused.

Add the JSON-valued lookup beside the string one and use it there, which is
what upstream does. The disjointness rule already caught the shared and
per-recipient cases after the fact; this is the protected header, and it now
fails before the key agreement runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
The same defect the RSA import was fixed for: an EC JWK whose "d" is there but
holds an empty string, a JSON null or base64url padding on its own imported as
a public key rather than failing, so a caller who believed they had a signing
key got one that cannot sign. RFC 7518 section 6.2.2 makes "d" the private key,
present for a private key and absent for a public one; there is no third state
in which it is present and means nothing.

Route it through _cjose_jwk_decode_private_attribute, which the RSA import
already uses, and record the two behaviour changes that came with validating
the shared unprotected header at encryption time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
@zandbelt

Copy link
Copy Markdown
Contributor Author

Done, in f18ae38. CMakeLists.txt, configure.ac and the generated configure all say 0.8.0 again — configure is now byte-identical to the tag, so the whole diff is fixes and tests.

Two related things I did along with it, both easy to undo if you would rather:

  • The CHANGELOG section is headed Unreleased rather than 0.8.1, on the same reasoning: the number is yours to assign at release. The content is still there, because it is where the behaviour changes are written down and I pointed at it in the description above. Say the word and I will drop the section entirely and leave the notes to you.
  • test/Makefile.in keeps its one change. That is not a version bump: it is the out-of-tree include path fix, and the file is tracked on this line, so test/Makefile.am alone would have had no effect. In-tree and out-of-tree make check both pass.

I also dropped "and release as 0.8.1" from that commit's subject, since it no longer does that.

Rebased rather than reverted, so no version bump is left anywhere in the history. Nine commits, each still building and passing on its own, 122 checks with CJOSE_ENABLE_RSA1_5 off and 121 on.

@TheStormN TheStormN linked an issue Sep 13, 2026 that may be closed by this pull request
@TheStormN
TheStormN merged commit 78ec462 into cisco:0.8.x Sep 13, 2026
31 checks passed
@zandbelt
zandbelt deleted the version-0.8.x branch September 14, 2026 06:17
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.

Propose a 0.8.x maintenance line for users on OpenSSL 1.x

2 participants