Skip to content

Commit 236a41d

Browse files
author
constanze
committed
fix(pem): use CRYPTO_memcmp and cpp_jwt's base64 instead of hand-rolled ones
Review feedback on #2401. constantTimeEquals hand-rolled the XOR-accumulate compare; BoringSSL ships CRYPTO_memcmp for exactly this, so call it. Length is still compared first — the signature length follows from the algorithm, not the secret, so an early exit there leaks nothing about the key. base64UrlDecode hand-rolled the URL-safe alphabet translation on top of absl::Base64Unescape; cpp_jwt's base64_uri_decode does the same job and is the one part of that library that needs no BIO, so it links against our BoringSSL. It is lenient where ours was strict — a non-alphabet byte yields a partial decode instead of an error — which changes no behaviour here: the HMAC over header.payload is verified BEFORE the payload is decoded, and a truncated decode then fails to parse as JSON. NonBase64UrlCharInPayload_Unauthenticated pins that so it cannot regress behind either decoder. Reworded the cpp_jwt rationale comment. BIO_f_base64 IS declared in BoringSSL's public bio.h; it is the implementation in decrepit/bio/base64_bio.c that @boringssl//:crypto does not build. Verified by linking a probe that calls jwt::decode(..., verify(true)): 'ld.lld: error: undefined symbol: BIO_f_base64'. Signing works today (shared/manager/manager.cc) because HMACSign<>::sign takes the header-only base64 path and never touches a BIO. Verified with --config=x86_64_sysroot: default 35 tests, 35 passed --//…:direct_query=false 3 tests, 3 passed
1 parent eeff121 commit 236a41d

3 files changed

Lines changed: 40 additions & 33 deletions

File tree

src/vizier/services/agent/pem/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ pl_cc_library(
7575
"//src/carnot/udf:cc_library",
7676
# HS256 verify uses BoringSSL HMAC directly and parses claims via rapidjson.
7777
"@boringssl//:crypto",
78+
"@com_github_arun11299_cpp_jwt//:cpp_jwt",
7879
"@com_github_grpc_grpc//:grpc++",
7980
"@com_github_rlyeh_sole//:sole",
8081
"@com_github_tencent_rapidjson//:rapidjson",

src/vizier/services/agent/pem/direct_query_server.cc

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
// "missing include". The disabled build pays a few KB of unused header
3131
// parse cost; the .cc emits nothing for them.
3232
#include <openssl/hmac.h>
33+
#include <openssl/mem.h>
3334
#include <openssl/sha.h>
3435
#include <rapidjson/document.h>
3536

@@ -39,10 +40,10 @@
3940
#include <string>
4041
#include <vector>
4142

42-
#include <absl/strings/escaping.h>
4343
#include <absl/strings/str_split.h>
4444
#include <absl/strings/string_view.h>
4545
#include <absl/strings/substitute.h>
46+
#include <jwt/base64.hpp>
4647
#include <sole.hpp>
4748

4849
// Compile-time kill switch. When PX_PEM_DIRECT_QUERY_DISABLED is defined
@@ -78,10 +79,16 @@ constexpr char kExpectedIssuer[] = "PL";
7879
// the serviceID, e.g. "dx"). See GenerateJWTForService (claims.go) + jwt.go:56.
7980
constexpr char kServiceScope[] = "service";
8081

81-
// We don't link cpp_jwt's HMAC verifier here because its impl calls
82-
// BIO_f_base64() which lives in BoringSSL's decrepit/ tree — not exposed as a
83-
// bazel target on this fork. Instead we parse the JWT envelope manually and
84-
// HMAC with BoringSSL natively. ~50 lines vs. carrying a boringssl patch.
82+
// cpp_jwt mints our outgoing service tokens (shared/manager/manager.cc), but we
83+
// cannot use it to VERIFY here: HMACSign<>::verify (impl/algorithm.ipp) base64s
84+
// through BIO_f_base64(). BoringSSL declares that in the public bio.h but
85+
// implements it in decrepit/bio/base64_bio.c, which @boringssl//:crypto does not
86+
// build — linking a jwt::decode(..., verify(true)) call fails with
87+
// `undefined symbol: BIO_f_base64`. (Signing links because HMACSign<>::sign uses
88+
// HMAC() plus cpp_jwt's header-only base64, no BIO.) Using the library for
89+
// verification would mean patching the BoringSSL external to add a decrepit
90+
// target; instead we parse the envelope here and HMAC with BoringSSL natively.
91+
// Its base64url decoder needs no BIO, so we do reuse that below.
8592

8693
// stripBearerPrefix returns the token slice after a case-insensitive "Bearer "
8794
// prefix, or an empty string if the prefix is missing. gRPC normalises metadata
@@ -100,39 +107,21 @@ absl::string_view stripBearerPrefix(absl::string_view value) {
100107
return value.substr(kBearerPrefixLen);
101108
}
102109

103-
// constantTimeEquals: short-circuit-free byte compare. Mismatched-length inputs
104-
// trivially differ but we still walk the shorter to keep timing predictable
105-
// across malformed lengths.
110+
// constantTimeEquals: BoringSSL's CRYPTO_memcmp, which is the library's own
111+
// constant-time comparison — no hand-rolled crypto here. Length is compared
112+
// first: the signature length is a function of the algorithm, not of the
113+
// secret, so leaking "wrong length" leaks nothing about the key.
106114
bool constantTimeEquals(absl::string_view a, absl::string_view b) {
107115
if (a.size() != b.size()) return false;
108-
uint8_t acc = 0;
109-
for (size_t i = 0; i < a.size(); ++i) {
110-
acc |= static_cast<uint8_t>(a[i] ^ b[i]);
111-
}
112-
return acc == 0;
116+
return CRYPTO_memcmp(a.data(), b.data(), a.size()) == 0;
113117
}
114118

115-
// base64UrlDecode handles RFC 7515 base64url (no padding, '-' / '_' alphabet).
116-
// Returns false on any non-alphabet character.
119+
// base64UrlDecode handles RFC 7515 base64url (no padding, '-' / '_' alphabet),
120+
// delegating the transform to cpp_jwt's header-only decoder (the one part of
121+
// that library that needs no BIO, so it links against our BoringSSL).
117122
bool base64UrlDecode(absl::string_view in, std::string* out) {
118-
// absl handles standard base64 with '+'/'/'; translate URL-safe alphabet and
119-
// pad to a multiple of 4 first.
120-
std::string std_b64;
121-
std_b64.reserve(in.size() + 4);
122-
for (char c : in) {
123-
if (c == '-') {
124-
std_b64.push_back('+');
125-
} else if (c == '_') {
126-
std_b64.push_back('/');
127-
} else if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
128-
c == '+' || c == '/') {
129-
std_b64.push_back(c);
130-
} else {
131-
return false;
132-
}
133-
}
134-
while (std_b64.size() % 4 != 0) std_b64.push_back('=');
135-
return absl::Base64Unescape(std_b64, out);
123+
*out = jwt::base64_uri_decode(in.data(), in.size());
124+
return true;
136125
}
137126

138127
// hmacSha256: BoringSSL HMAC over `data`, returns raw 32 bytes.

src/vizier/services/agent/pem/direct_query_server_test.cc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,23 @@ TEST_F(DirectQueryServerTest, ConcatenatedTokens_Unauthenticated) {
446446
// is the explicit "alg substitution" attack from RFC 8725 §2.6 (the more
447447
// classic variant uses RS256 / public-key confusion; we don't sign with
448448
// asymmetric keys so the HMAC-flavoured variant is what we guard against).
449+
// Segments containing characters outside the base64url alphabet must be
450+
// rejected. The decoder itself is allowed to be lenient — cpp_jwt's header-only
451+
// base64 returns a partial decode rather than an error — so this pins the
452+
// property at the level that matters: a token whose payload is not valid
453+
// base64url never authenticates, no matter which decoder is underneath. It is
454+
// the HMAC over header.payload that closes the door, and the truncated JSON
455+
// behind it fails to parse anyway.
456+
TEST_F(DirectQueryServerTest, NonBase64UrlCharInPayload_Unauthenticated) {
457+
auto tok = MakeBearerToken(kTestSigningKey, TokenKind::kValid);
458+
auto [p_start, p_end] = SegmentIndex(tok, 1);
459+
ASSERT_NE(std::string::npos, p_start);
460+
ASSERT_GT(p_end, p_start + 1);
461+
auto corrupted = tok;
462+
corrupted[p_start + 1] = '!'; // '!' is not in the base64url alphabet.
463+
EXPECT_EQ(::grpc::StatusCode::UNAUTHENTICATED, CallExecuteScript(corrupted).error_code());
464+
}
465+
449466
TEST_F(DirectQueryServerTest, AlgConfusion_HS384_Unauthenticated) {
450467
// Header: {"alg":"HS384","typ":"JWT"} → base64url, no padding.
451468
constexpr char kHS384Header[] = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9";

0 commit comments

Comments
 (0)