Skip to content

Refuse an RSA key whose private members are present but carry no value - #189

Merged
TheStormN merged 1 commit into
cisco:masterfrom
OpenIDC:jwk-empty-private-member
Sep 12, 2026
Merged

TheStormN merged 1 commit into
cisco:masterfrom
OpenIDC:jwk-empty-private-member

Conversation

@zandbelt

@zandbelt zandbelt commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

_cjose_jwk_decode_json_object_base64url_attribute() reports an attribute that is present but empty or null exactly as it reports an absent one: a NULL buffer and a true return. The RSA import takes that at face value for every private member, so

{"kty":"RSA","n":"...","e":"AQAB","d":""}

imports as a public RSA key instead of being refused, and the same holds for "d":null and for p, q, dp, dq and qi in either shape. RFC 7517 gives each of those members a base64url-encoded value, so a key of that shape is malformed and reinterpreting it as a public key hides the error from the caller.

The six decodes now go through a helper that keeps the distinction between an absent member and a valueless one. The required public members need no such guard: a valueless n or e already fails when the key is built, which the test asserts stays true.

This is the third key type to get the rule. The OKP import has made the distinction since it was added, and the EC import gets it in #186, which found this class of bug: an epk of {"kty":"EC",...,"d":""} was imported as public and accepted as an ephemeral key, against RFC 7518 section 4.6.1.1. RSA can never be an epk, so no JWE or JWS rule is involved here, but the import behaviour is the same and is worth settling. Once both have landed the three imports can share this helper; I kept them apart rather than editing code that is in flight.

Testing

  • A new jwk suite test builds a random 2048-bit RSA key and, for each of the six private members in both shapes, appends it to the key's public serialization and requires the import to fail with CJOSE_ERR_INVALID_ARG — twelve cases. It also requires the public and the full private serialization of the same key to keep importing. It fails on master with "a d of "" was accepted".
  • Full check_cjose with RSA1_5 off and on, valgrind clean over the whole run, clang-format produces no diff.

🤖 Generated with Claude Code

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
@TheStormN
TheStormN merged commit cee8d58 into cisco:master Sep 12, 2026
27 checks passed
TheStormN pushed a commit to OpenIDC/cjose that referenced this pull request Sep 13, 2026
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>
TheStormN pushed a commit that referenced this pull request Sep 13, 2026
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. #189 tested
the decoded buffer pointer, #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.

Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
TheStormN pushed a commit that referenced this pull request Sep 15, 2026
The 1.0.0 section carried only the Breaking list that #187 wrote, so nothing
merged after it was recorded: the A*GCMKW, PBES2, X25519/X448 and ML-DSA
algorithms, the "crit" refusal and the JWE header disjointness, the JWK import
refusals of #189, #190, #192 and #193, the NULL cjose_err crash of #191 and the
EVP_Q_mac change of #196. The entries reference pull requests, as the rest of
that section does, rather than the commit links the released sections use.

The "crit" refusal is listed as breaking because it refuses a JWE or JWS that
0.8.0 accepted. Three more rules do the same without changing the API, so they
stay under Fix and a Compatibility paragraph names them, the way the 0.8.1
notes do: the disjointness of the header locations, the refusal of a header
parameter the algorithm generates, and the refusal of a valueless private
member in an RSA or EC key.

0.8.1 was released from the 0.8.x branch on 2026-09-14 and its section only
ever existed there, so this file jumped from the unreleased 1.0.0 straight to
0.8.0 and the release was invisible here. It is copied over unchanged.

The README already describes the ML-DSA algorithms, the AKP key type and the
CJOSE_ENABLE_ML_DSA option; the only thing missing was the OpenSSL requirement
in the prerequisites, which named 3.0.0 alone.

Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
TheStormN pushed a commit that referenced this pull request Sep 15, 2026
Three sentences #199 added describe the 0.8.x line or an earlier commit
rather than what main does, found by a review of the merged text against
the source at 0.8.0 and at each pull request.

The generated-parameter entry said the check for a caller-supplied "epk" had
read it as a string and so never saw one. That defect existed only on the
0.8.x backport, whose 0.8.1 notes the sentence was taken from; on main the
helper has looked the parameter up as JSON since #188 introduced it. What
0.8.0 did on main was silently replace a protected "epk" with the generated
one, and leave one in the shared or a per-recipient header beside it.

The RSA private-member entry gave "imported as a public key" as the outcome
for every valueless form. That is true of an empty, null or padding-only
member (#189, #192) but not of a zero one (#193), which imported as a private
key whose export cjose could not read back, as the 0.8.1 section below it
already says.

The Compatibility paragraph counted three rules that refuse input 0.8.0
accepted and missed two: the "oth" refusal of #190, since 0.8.0 knew no such
member and imported a multi-prime key as a two-prime one, and the refusal of
an "epk" naming a private member of #186. It also listed "iv", "tag" and
"p2s" as if 0.8.0 had accepted them, when the algorithms that use them are
new in this release. The paragraph now names the rules without counting them.

Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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