Skip to content

Commit c7664ee

Browse files
authored
Misc OAuth/JOSE/Crypto/OIDC hardening (#2689)
Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>
1 parent 6a6fa18 commit c7664ee

20 files changed

Lines changed: 578 additions & 143 deletions

src/core/crypto/crypto_pkcs8.h

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,60 @@ struct PKCS8Key {
6161
bool rsa_pss_restricted{false};
6262
};
6363

64+
// Whether a PKCS#1 RSAPrivateKey carries usable components (RFC 8017 Appendix
65+
// A.1.2). The modulus and both exponents are decoded as canonical non-negative
66+
// DER INTEGERs, so a negative or non-canonically encoded one cannot be silently
67+
// reinterpreted as a different positive number and signed with, and the public
68+
// exponent is range-checked per RFC 8017 Section 3.1. The version is required
69+
// to be present as an INTEGER but its value is left to the backends, which
70+
// differ on whether they accept the multi-prime form. Checked here rather than
71+
// in each backend, since only the reference one reads these components itself
72+
// and the rest hand the blob straight to a platform that does not apply the
73+
// rule
74+
inline auto rsa_private_key_acceptable(const std::string_view key) -> bool {
75+
// A canonical RSAPrivateKey is exactly one SEQUENCE, so bytes trailing it
76+
// mark a malformed encoding (X.690 Section 10.1), the same rule the enclosing
77+
// PrivateKeyInfo is held to
78+
const auto sequence{der_read(key)};
79+
if (!sequence.has_value() || sequence->tag != 0x30 ||
80+
!sequence->rest.empty()) {
81+
return false;
82+
}
83+
84+
const auto version{der_read(sequence->content)};
85+
if (!version.has_value() || version->tag != 0x02) {
86+
return false;
87+
}
88+
89+
const auto modulus{der_read(version->rest)};
90+
if (!modulus.has_value() || modulus->tag != 0x02) {
91+
return false;
92+
}
93+
94+
const auto public_exponent{der_read(modulus->rest)};
95+
if (!public_exponent.has_value() || public_exponent->tag != 0x02) {
96+
return false;
97+
}
98+
99+
const auto private_exponent{der_read(public_exponent->rest)};
100+
if (!private_exponent.has_value() || private_exponent->tag != 0x02) {
101+
return false;
102+
}
103+
104+
const auto modulus_value{der_unsigned_integer(modulus->content)};
105+
const auto public_exponent_value{
106+
der_unsigned_integer(public_exponent->content)};
107+
const auto private_exponent_value{
108+
der_unsigned_integer(private_exponent->content)};
109+
return modulus_value.has_value() && public_exponent_value.has_value() &&
110+
private_exponent_value.has_value() && !modulus_value->empty() &&
111+
!private_exponent_value->empty() &&
112+
modulus_value->size() <= MAXIMUM_KEY_BYTES &&
113+
private_exponent_value->size() <= MAXIMUM_KEY_BYTES &&
114+
rsa_public_exponent_acceptable(public_exponent_value.value(),
115+
modulus_value.value());
116+
}
117+
64118
// Parse an RFC 5958 PrivateKeyInfo, identifying the algorithm from its object
65119
// identifier and returning the raw privateKey octets
66120
inline auto parse_pkcs8(const std::string_view der) -> std::optional<PKCS8Key> {
@@ -106,6 +160,10 @@ inline auto parse_pkcs8(const std::string_view der) -> std::optional<PKCS8Key> {
106160
// NOLINTEND(modernize-raw-string-literal)
107161

108162
if (oid->content == rsa || oid->content == rsa_pss) {
163+
if (!rsa_private_key_acceptable(private_key->content)) {
164+
return std::nullopt;
165+
}
166+
109167
return PKCS8Key{.kind = PKCS8KeyKind::RSA,
110168
.curve = {},
111169
.edwards_curve = {},

src/core/crypto/crypto_sign_windows.cc

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,20 @@ auto native_rsa_private_key(const std::string_view rsa_private_key) -> KeyPair {
119119
return {.algorithm = nullptr, .key = nullptr};
120120
}
121121

122-
field = sourcemeta::core::strip_left(element->content, '\x00');
122+
// Decoded as a canonical non-negative DER INTEGER rather than merely having
123+
// its leading zeros stripped, so a value whose leading octet sets the high
124+
// bit, which X.690 Section 8.3 makes negative, cannot be read as a large
125+
// positive one
126+
const auto value{sourcemeta::core::der_unsigned_integer(element->content)};
123127
// Every field feeds a blob length or padding width, so oversize input is
124128
// rejected before it drives a cast or an allocation
125-
if (field.size() > sourcemeta::core::MAXIMUM_KEY_BYTES) {
129+
if (!value.has_value() ||
130+
value.value().size() > sourcemeta::core::MAXIMUM_KEY_BYTES) {
126131
return {.algorithm = nullptr, .key = nullptr};
127132
}
128133

134+
field = value.value();
135+
129136
rest = element->rest;
130137
}
131138

src/core/jose/include/sourcemeta/core/jose_verify.h

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,32 @@ struct JWTClockSkew {
7979
std::chrono::seconds issued_at{0};
8080
};
8181

82+
/// @ingroup jose
83+
/// Reduce a caller-supplied clock skew to the grace period token validation
84+
/// honours, one mean Gregorian year, treating a negative tolerance as none.
85+
/// Every path that validates a token applies this, so the same tolerance means
86+
/// the same thing whichever one runs. For example:
87+
///
88+
/// ```cpp
89+
/// #include <sourcemeta/core/jose.h>
90+
/// #include <chrono>
91+
/// #include <cassert>
92+
///
93+
/// assert(sourcemeta::core::jwt_bounded_clock_skew(
94+
/// std::chrono::seconds{30}) == std::chrono::seconds{30});
95+
/// assert(sourcemeta::core::jwt_bounded_clock_skew(
96+
/// std::chrono::seconds{-30}) == std::chrono::seconds{0});
97+
/// ```
98+
inline auto jwt_bounded_clock_skew(const std::chrono::seconds skew) noexcept
99+
-> std::chrono::seconds {
100+
// A mean Gregorian year, the widest grace period any deployment plausibly
101+
// needs, so an extreme value cannot widen the acceptance window without bound
102+
constexpr std::chrono::seconds maximum{31556952};
103+
return skew < std::chrono::seconds::zero() ? std::chrono::seconds::zero()
104+
: skew > maximum ? maximum
105+
: skew;
106+
}
107+
82108
/// @ingroup jose
83109
/// Validate the registered claims of a JSON Web Token against the expected
84110
/// issuer and audience at a given time, returning the first failing check or no

src/core/jose/jose_jwt_check_claims.cc

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include <sourcemeta/core/jose_verify.h>
22

3-
#include <algorithm> // std::clamp
3+
#include <sourcemeta/core/time.h>
4+
45
#include <chrono> // std::chrono::seconds, std::chrono::system_clock
56
#include <optional> // std::optional, std::nullopt
67
#include <string_view> // std::string_view
@@ -11,32 +12,19 @@ using Clock = std::chrono::system_clock;
1112

1213
// The skew is applied to the server clock rather than the attacker-controlled
1314
// claim, so a NumericDate near the representable bound cannot overflow the
14-
// comparison (the two forms are otherwise equivalent). The skew is also
15-
// clamped to a non-negative, bounded grace period and the shift saturates, so
16-
// an extreme caller-supplied clock or skew stays well-defined too
17-
auto skew_ticks(const std::chrono::seconds skew) -> Clock::duration {
18-
return std::chrono::duration_cast<Clock::duration>(std::clamp(
19-
skew, std::chrono::seconds::zero(), std::chrono::seconds{31556952}));
20-
}
21-
15+
// comparison (the two forms are otherwise equivalent). The shift itself
16+
// saturates, and the skew is bounded first, so an extreme caller-supplied clock
17+
// or skew stays well-defined too
2218
auto shift_backward(const Clock::time_point now,
2319
const std::chrono::seconds skew) -> Clock::time_point {
24-
const auto ticks{skew_ticks(skew)};
25-
if (now.time_since_epoch() < Clock::duration::min() + ticks) {
26-
return Clock::time_point{Clock::duration::min()};
27-
}
28-
29-
return now - ticks;
20+
return sourcemeta::core::clock_shift_backward(
21+
now, sourcemeta::core::jwt_bounded_clock_skew(skew));
3022
}
3123

3224
auto shift_forward(const Clock::time_point now, const std::chrono::seconds skew)
3325
-> Clock::time_point {
34-
const auto ticks{skew_ticks(skew)};
35-
if (now.time_since_epoch() > Clock::duration::max() - ticks) {
36-
return Clock::time_point{Clock::duration::max()};
37-
}
38-
39-
return now + ticks;
26+
return sourcemeta::core::clock_shift_forward(
27+
now, sourcemeta::core::jwt_bounded_clock_skew(skew));
4028
}
4129

4230
} // namespace

src/core/oauth/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ target_link_libraries(sourcemeta_core_oauth
2626
PRIVATE sourcemeta::core::text)
2727
target_link_libraries(sourcemeta_core_oauth
2828
PRIVATE sourcemeta::core::html)
29+
target_link_libraries(sourcemeta_core_oauth
30+
PRIVATE sourcemeta::core::time)
2931

3032
if(SOURCEMETA_CORE_INSTALL)
3133
sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME oauth)

src/core/oauth/include/sourcemeta/core/oauth_metadata.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ class SOURCEMETA_CORE_OAUTH_EXPORT OAuthServerMetadata {
117117
[[nodiscard]] auto registration_endpoint() const
118118
-> std::optional<std::string_view>;
119119

120+
/// The device authorization endpoint (RFC 8628 Section 4).
121+
[[nodiscard]] auto device_authorization_endpoint() const
122+
-> std::optional<std::string_view>;
123+
120124
/// The token revocation endpoint (RFC 8414 Section 2).
121125
[[nodiscard]] auto revocation_endpoint() const
122126
-> std::optional<std::string_view>;

src/core/oauth/oauth_dpop.cc

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include <sourcemeta/core/jose_verify.h>
99
#include <sourcemeta/core/json.h>
1010
#include <sourcemeta/core/oauth_random.h>
11+
#include <sourcemeta/core/time.h>
1112

1213
#include "oauth_json.h"
1314
#include "oauth_syntax.h"
@@ -29,27 +30,6 @@ namespace {
2930

3031
using namespace std::literals::string_view_literals;
3132

32-
// When an entry stops guarding against a replay, saturating at the newest
33-
// instant the clock can express. A caller derives the window from a token
34-
// lifetime, so the value is attacker-influenced and adding it to the clock
35-
// unchecked would overflow, wrap negative, and have the entry pruned on the
36-
// very next call, silently disabling the replay guard it was inserted for
37-
[[maybe_unused]] auto
38-
replay_expiry(const std::chrono::system_clock::time_point now,
39-
const std::chrono::seconds window)
40-
-> std::chrono::system_clock::time_point {
41-
using Clock = std::chrono::system_clock;
42-
constexpr auto limit{
43-
std::chrono::duration_cast<std::chrono::seconds>(Clock::duration::max())};
44-
const auto ticks{std::chrono::duration_cast<Clock::duration>(
45-
std::clamp(window, std::chrono::seconds::zero(), limit))};
46-
if (now.time_since_epoch() > Clock::duration::max() - ticks) {
47-
return Clock::time_point{Clock::duration::max()};
48-
}
49-
50-
return now + ticks;
51-
}
52-
5333
constexpr auto HASH_TYP{JSON::Object::hash("typ"sv)};
5434
constexpr auto HASH_ALG{JSON::Object::hash("alg"sv)};
5535
constexpr auto HASH_JWK{JSON::Object::hash("jwk"sv)};
@@ -343,8 +323,8 @@ auto oauth_dpop_verify(const std::string_view proof,
343323
}
344324

345325
// Check 11: the creation time is within the acceptable window
346-
if (issued.value() < now - options.past_window ||
347-
issued.value() > now + options.future_window) {
326+
if (issued.value() < clock_shift_backward(now, options.past_window) ||
327+
issued.value() > clock_shift_forward(now, options.future_window)) {
348328
return OAuthDPoPError::Expired;
349329
}
350330

@@ -425,7 +405,7 @@ auto OAuthDPoPReplayStore::check_and_insert(
425405
}
426406

427407
this->entries_.push_back(
428-
Entry{.digest = digest, .expiry = replay_expiry(now, window)});
408+
Entry{.digest = digest, .expiry = clock_shift_forward(now, window)});
429409
return true;
430410
}
431411

src/core/oauth/oauth_metadata.cc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ auto OAuthServerMetadata::registration_endpoint() const
357357
HASH_REGISTRATION_ENDPOINT);
358358
}
359359

360+
auto OAuthServerMetadata::device_authorization_endpoint() const
361+
-> std::optional<std::string_view> {
362+
return string_member(this->data_, "device_authorization_endpoint"sv,
363+
HASH_DEVICE_AUTHORIZATION_ENDPOINT);
364+
}
365+
360366
auto OAuthServerMetadata::revocation_endpoint() const
361367
-> std::optional<std::string_view> {
362368
return string_member(this->data_, "revocation_endpoint"sv,

src/core/oidc/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME oidc
44
request_object.h logout.h encryption.h profile.h
55
SOURCES oidc_error.cc oidc_metadata.cc oidc_discovery.cc oidc_hash.cc
66
oidc_id_token.cc oidc_authentication.cc oidc_claims.cc oidc_userinfo.cc
7-
oidc_registration.cc oidc_subject.cc oidc_verify.h oidc_time.h
7+
oidc_registration.cc oidc_subject.cc oidc_verify.h
88
oidc_request_object.cc oidc_logout.cc oidc_encryption.cc)
99

1010
target_link_libraries(sourcemeta_core_oidc

src/core/oidc/oidc_id_token.cc

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
#include <sourcemeta/core/oidc_hash.h>
77
#include <sourcemeta/core/time.h>
88

9-
#include "oidc_time.h"
10-
119
#include <chrono> // std::chrono::system_clock, std::chrono::seconds, std::chrono::duration, std::chrono::duration_cast
1210
#include <cstdint> // std::int64_t
1311
#include <optional> // std::optional, std::nullopt
@@ -106,7 +104,7 @@ auto oidc_id_token_checks(const JWT &token, const std::string_view issuer,
106104
// policy
107105
if (options.maximum_issued_at_age.has_value() &&
108106
issued_at.value() <
109-
oidc_shift_backward(now, options.maximum_issued_at_age.value())) {
107+
clock_shift_backward(now, options.maximum_issued_at_age.value())) {
110108
return std::nullopt;
111109
}
112110

@@ -154,8 +152,8 @@ auto oidc_id_token_checks(const JWT &token, const std::string_view issuer,
154152
// satisfy a freshness window and is rejected before the age comparison
155153
if (!authentication_time.has_value() || authentication_time.value() > now ||
156154
authentication_time.value() <
157-
oidc_shift_backward(now,
158-
options.maximum_authentication_age.value())) {
155+
clock_shift_backward(now,
156+
options.maximum_authentication_age.value())) {
159157
return std::nullopt;
160158
}
161159
}

0 commit comments

Comments
 (0)